diff --git a/.windsurfrules b/.windsurfrules
new file mode 100644
index 0000000..cb21241
--- /dev/null
+++ b/.windsurfrules
@@ -0,0 +1,101 @@
+# Product Vision: “Keep My Weight” Tracker
+
+## Vision Statement
+Empower health-conscious individuals to effortlessly monitor and understand their weight journey and daily nutrition through a minimalist, privacy-first web app—delivering clear insights, seamless data portability, and an intuitive, distraction-free experience.
+
+---
+
+## Target Audience
+- **Health Enthusiasts** & people aiming for weight goals
+- Users seeking a **lightweight**, self-hosted solution
+- Privacy-minded individuals who prefer **local data storage** and full control
+- Non-technical users who appreciate a **zero-setup** interface
+
+---
+
+## Key User Needs
+1. **Quick Weight Logging**
+ - Log weight in kilograms with minimal friction
+ - Separate weight entries from meal logs
+2. **Daily Meal Tracking**
+ - Record Breakfast, Lunch, Dinner, and “Other” items
+ - Flexible text fields, no calorie counting required
+3. **Progress Reporting**
+ - Clean, exportable charts & tables of weight evolution
+ - Ability to filter by date ranges
+4. **Data Portability & Backup**
+ - Import / Export complete data as JSON
+ - CSV export for spreadsheets or external analysis
+5. **Simplicity & Reliability**
+ - Zero-backend static site
+ - Dockerized for easy deployment via Coolify or similar
+
+---
+
+## Product Pillars
+
+### 1. **Usability & Clarity**
+- **Single-page interface**: instant access to weight & meal logs
+- **Clean layout**: large inputs, clear typography, mobile-friendly
+- Save weight **independently** of meals
+
+### 2. **Insightful Reporting**
+- **Interactive weight chart**: line graph of weight over time
+- **Tabular view**: sortable, filterable by date
+- **Exportable**: CSV download or copy-paste
+
+### 3. **Data Ownership**
+- All entries stored in a single `data.json` file in root
+- **Import / Export** buttons for manual backup & restore
+- Encrypted if desired by advanced users
+
+### 4. **Lightweight Tech Stack**
+- **Static site** built with vanilla JS (or minimal framework) + HTML/CSS
+- **Data layer**: read/write `data.json` via JavaScript filesystem APIs (or embedded file picker)
+- **Dockerized**: simple `Dockerfile` wrapping a static file server (e.g., Caddy, NGINX)
+- **Deploy**: Coolify recipe, GitHub → Docker → Cloud
+
+---
+
+## Minimum Viable Roadmap
+
+| Phase | Deliverables |
+|-----|-------------|
+| **Phase 1** | • Weight log UI + persistence in `data.json`
+ | • Meal log UI (4 sections) + same JSON file
+ | • Save & Load from JSON via file picker
+| **Phase 2** | • Weight evolution line chart
+ | • CSV export of all entries
+ | • Date-range filters
+| **Phase 3** | • JSON import (merge vs overwrite)
+ | • Responsive mobile-first refinements
+ | • Docker & Coolify deployment docs
+| **Phase 4** | • Theming (light/dark)
+ | • Localization support
+ | • Retirement migration guide (e.g., to new data formats)
+
+---
+
+## High-Level Architecture
+
+```text
+┌─────────────────────┐ ┌───────────────┐
+│ Static Web Server │◀────▶│ data.json │
+│ (NGINX/Caddy) │ │ (local FS) │
+└─────────────────────┘ └───────────────┘
+ ▲ ▲
+ │ │
+ │ │
+ Vanilla JS HTML/CSS
+ (UI & Data I/O)
+Frontend:
+
+ ES6 modules, no build step (or minimal bundler)
+
+ Fetch & download JSON via /
+
+Server:
+
+ Containerized static file server
+
+ Serves index.html, static assets, and data.json
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..fcc865a
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,46 @@
+# Multi-stage build for smaller final image
+FROM node:18-alpine AS base
+
+# Install dependencies for the data API
+WORKDIR /app
+COPY package*.json ./
+RUN npm install --production
+
+# Build stage for the web application
+FROM node:18-alpine AS web-builder
+WORKDIR /app
+
+# Copy all application files
+COPY . .
+
+# Final stage
+FROM nginx:alpine
+
+# Create directory for persistent data storage
+RUN mkdir -p /data && chmod -R 755 /data
+
+# Install Node.js for the data API
+RUN apk add --no-cache nodejs npm supervisor
+
+# Copy the static website files to the Nginx serving directory
+COPY --from=web-builder /app /usr/share/nginx/html
+
+# Copy the Node.js dependencies and data API
+COPY --from=base /app/node_modules /usr/share/nginx/api/node_modules
+COPY data-api.js /usr/share/nginx/api/
+
+# Copy a custom Nginx configuration that includes the data API proxy
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+# Copy supervisor configuration
+COPY supervisord.conf /etc/supervisord.conf
+
+# Clean up unnecessary files from the HTML directory
+RUN cd /usr/share/nginx/html && \
+ rm -rf node_modules Dockerfile docker-compose.yml nginx.conf supervisord.conf data-api.js package*.json .git* .vscode
+
+# Expose port 80
+EXPOSE 80
+
+# Start supervisor which will manage both Nginx and Node.js
+CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
diff --git a/README.md b/README.md
index 23a5a4b..284f774 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,97 @@
-A website to track weight and daily meals.
+# Keep My Weight Tracker
+
+A minimalist, privacy-first web application for tracking your weight and daily meals. This application is designed to be simple, lightweight, and focused on data ownership.
+
+## Features
+
+- **Weight Tracking**: Log your weight with optional notes
+- **Meal Logging**: Record your daily breakfast, lunch, dinner, and other meals
+- **Data Visualization**: View your weight progress on an interactive chart
+- **Data Export/Import**: Export and import your data as JSON or CSV
+- **Privacy-First**: All data stays on your device using browser's localStorage
+- **Mobile-Friendly**: Responsive design works on all devices
+
+## Getting Started
+
+1. Clone this repository or download the source code
+2. Open `index.html` in your web browser
+3. Start logging your weight and meals!
+
+No installation or setup required. The application works entirely in your browser.
+
+## Data Storage
+
+All your data is stored locally in your browser using localStorage. No data is sent to any server.
+
+- To backup your data, use the "Export Data" button in the Settings tab
+- To restore a backup, use the "Import Data" button in the Settings tab
+
+## Technical Details
+
+This application is built with:
+
+- **Frontend**: HTML5, CSS3, and vanilla JavaScript (ES6+)
+- **Data Storage**: Browser localStorage
+- **Charts**: Chart.js library
+- **Dependencies**: None, except Chart.js which is loaded from CDN
+
+## Deployment
+
+### Local Development
+
+Simply open `index.html` in your web browser.
+
+### Docker Deployment
+
+The application includes a complete Docker setup with persistent data storage.
+
+```bash
+# Build and run with Docker Compose
+docker-compose up -d
+```
+
+This will create:
+- A container with both the web server and data API
+- A persistent volume for data storage at `/data`
+
+### Coolify Deployment
+
+To deploy on Coolify:
+
+1. In your Coolify dashboard, create a new service
+2. Select "Docker Compose" as the deployment type
+3. Connect to your Git repository or upload the files directly
+4. Ensure the volume is configured correctly for data persistence
+5. Deploy the application
+
+The application will be available at the URL provided by Coolify. All weight and meal data will be stored in the persistent volume, ensuring your data remains intact across container restarts or updates.
+
+## Roadmap
+
+### Phase 1 (Current)
+- Weight log UI + persistence
+- Meal log UI + persistence
+- Save & Load from JSON
+
+### Phase 2
+- Weight evolution line chart
+- CSV export of all entries
+- Date-range filters
+
+### Phase 3
+- JSON import (merge vs overwrite)
+- Responsive mobile-first refinements
+- Docker & Coolify deployment docs
+
+### Phase 4
+- Theming (light/dark)
+- Localization support
+- Migration guide
+
+## License
+
+MIT License
+
+## Privacy Policy
+
+This application does not collect any personal data. All data you enter remains on your device.
diff --git a/css/styles.css b/css/styles.css
new file mode 100644
index 0000000..bfa997b
--- /dev/null
+++ b/css/styles.css
@@ -0,0 +1,354 @@
+:root {
+ --primary-color: #4a6fa5;
+ --primary-dark: #3a5a8c;
+ --secondary-color: #61b15a;
+ --accent-color: #ffce56;
+ --text-color: #333;
+ --text-secondary: #666;
+ --background-color: #f8f9fa;
+ --card-bg: #ffffff;
+ --border-color: #e0e0e0;
+ --error-color: #d9534f;
+ --success-color: #5cb85c;
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ --border-radius: 8px;
+ --font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: var(--font-family);
+ line-height: 1.6;
+ background-color: var(--background-color);
+ color: var(--text-color);
+}
+
+.app-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+header {
+ text-align: center;
+ margin-bottom: 30px;
+ padding: 20px 0;
+}
+
+h1 {
+ color: var(--primary-color);
+ font-size: 2.5rem;
+ margin-bottom: 5px;
+}
+
+.tagline {
+ color: var(--text-secondary);
+ font-size: 1.1rem;
+}
+
+/* Tabs */
+.tabs {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 20px;
+ border-bottom: 2px solid var(--border-color);
+}
+
+.tab-btn {
+ padding: 10px 20px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 1rem;
+ color: var(--text-secondary);
+ transition: all 0.3s ease;
+ position: relative;
+}
+
+.tab-btn:hover {
+ color: var(--primary-color);
+}
+
+.tab-btn.active {
+ color: var(--primary-color);
+ font-weight: 600;
+}
+
+.tab-btn.active::after {
+ content: '';
+ position: absolute;
+ bottom: -2px;
+ left: 0;
+ width: 100%;
+ height: 2px;
+ background-color: var(--primary-color);
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* Cards */
+.card {
+ background-color: var(--card-bg);
+ border-radius: var(--border-radius);
+ padding: 20px;
+ margin-bottom: 30px;
+ box-shadow: var(--shadow);
+}
+
+h2 {
+ color: var(--primary-color);
+ margin-bottom: 20px;
+ font-size: 1.8rem;
+}
+
+h3 {
+ color: var(--text-color);
+ margin-bottom: 15px;
+ font-size: 1.4rem;
+}
+
+/* Forms */
+.form-group {
+ margin-bottom: 20px;
+}
+
+label {
+ display: block;
+ margin-bottom: 8px;
+ font-weight: 500;
+}
+
+input[type="date"],
+input[type="number"],
+textarea {
+ width: 100%;
+ padding: 12px;
+ border-radius: var(--border-radius);
+ border: 1px solid var(--border-color);
+ font-family: var(--font-family);
+ font-size: 1rem;
+}
+
+input[type="date"]:focus,
+input[type="number"]:focus,
+textarea:focus {
+ outline: none;
+ border-color: var(--primary-color);
+}
+
+.meal-sections {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 20px;
+}
+
+/* Buttons */
+.btn {
+ padding: 12px 24px;
+ border-radius: var(--border-radius);
+ border: none;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 1rem;
+ transition: all 0.3s ease;
+}
+
+.primary-btn {
+ background-color: var(--primary-color);
+ color: white;
+}
+
+.primary-btn:hover {
+ background-color: var(--primary-dark);
+}
+
+.secondary-btn {
+ background-color: white;
+ color: var(--primary-color);
+ border: 1px solid var(--primary-color);
+}
+
+.secondary-btn:hover {
+ background-color: #f0f5ff;
+}
+
+.text-btn {
+ background: none;
+ color: var(--primary-color);
+ padding: 12px 16px;
+}
+
+.text-btn:hover {
+ text-decoration: underline;
+}
+
+.btn-group {
+ display: flex;
+ gap: 15px;
+ margin-top: 15px;
+}
+
+/* Tables */
+.table-container {
+ overflow-x: auto;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 15px;
+}
+
+th, td {
+ padding: 12px 15px;
+ text-align: left;
+ border-bottom: 1px solid var(--border-color);
+}
+
+th {
+ background-color: #f8f9fa;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+/* Chart */
+.chart-container {
+ width: 100%;
+ height: 400px;
+ margin-top: 20px;
+}
+
+.chart-filters {
+ display: flex;
+ align-items: end;
+ flex-wrap: wrap;
+ gap: 15px;
+ margin-bottom: 20px;
+}
+
+.chart-filters .form-group {
+ margin-bottom: 0;
+ min-width: 180px;
+}
+
+.placeholder-message {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: var(--text-secondary);
+ font-style: italic;
+}
+
+/* File Input */
+.file-input-container {
+ display: flex;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+input[type="file"] {
+ position: absolute;
+ left: -9999px;
+}
+
+#file-name-display {
+ margin-left: 15px;
+ color: var(--text-secondary);
+}
+
+.import-options {
+ margin-bottom: 20px;
+}
+
+.radio-container {
+ display: flex;
+ align-items: center;
+ margin-bottom: 10px;
+ cursor: pointer;
+}
+
+.radio-text {
+ margin-left: 10px;
+}
+
+/* Features list */
+.features-list {
+ list-style-type: none;
+ margin: 15px 0;
+}
+
+.features-list li {
+ padding: 5px 0;
+ position: relative;
+ padding-left: 25px;
+}
+
+.features-list li::before {
+ content: '✓';
+ color: var(--secondary-color);
+ position: absolute;
+ left: 0;
+}
+
+/* Footer */
+footer {
+ text-align: center;
+ padding: 20px 0;
+ color: var(--text-secondary);
+ font-size: 0.9rem;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .tabs {
+ flex-wrap: wrap;
+ }
+
+ .tab-btn {
+ flex: 1 0 auto;
+ padding: 10px;
+ font-size: 0.9rem;
+ }
+
+ .card {
+ padding: 15px;
+ }
+
+ h1 {
+ font-size: 2rem;
+ }
+
+ h2 {
+ font-size: 1.5rem;
+ }
+
+ .chart-filters {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .chart-filters .form-group {
+ margin-bottom: 10px;
+ }
+
+ .btn {
+ width: 100%;
+ margin-bottom: 10px;
+ }
+
+ .btn-group {
+ flex-direction: column;
+ }
+}
diff --git a/data-api.js b/data-api.js
new file mode 100644
index 0000000..90e137b
--- /dev/null
+++ b/data-api.js
@@ -0,0 +1,85 @@
+/**
+ * Simple data API for Weight Tracker
+ * This script handles data storage operations when deployed in Docker
+ */
+
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+const bodyParser = require('body-parser');
+const cors = require('cors');
+
+// Create Express app
+const app = express();
+const port = process.env.PORT || 3000;
+
+// Data file path in the Docker volume
+const DATA_DIR = process.env.DATA_DIR || '/data';
+const DATA_FILE = path.join(DATA_DIR, 'weight-tracker-data.json');
+
+// Middleware
+app.use(cors());
+app.use(bodyParser.json({ limit: '5mb' }));
+app.use(express.static('public')); // Serve static files
+
+// Ensure data directory exists
+if (!fs.existsSync(DATA_DIR)) {
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ console.log(`Created data directory: ${DATA_DIR}`);
+}
+
+// Initialize data file if it doesn't exist
+if (!fs.existsSync(DATA_FILE)) {
+ const defaultData = {
+ weights: [],
+ meals: [],
+ version: '1.0.0'
+ };
+
+ fs.writeFileSync(DATA_FILE, JSON.stringify(defaultData, null, 2));
+ console.log(`Created initial data file: ${DATA_FILE}`);
+}
+
+// GET endpoint to retrieve data
+app.get('/data/weight-tracker-data.json', (req, res) => {
+ try {
+ if (fs.existsSync(DATA_FILE)) {
+ const data = fs.readFileSync(DATA_FILE, 'utf8');
+ res.setHeader('Content-Type', 'application/json');
+ res.send(data);
+ } else {
+ res.status(404).send({ error: 'Data file not found' });
+ }
+ } catch (error) {
+ console.error('Error reading data file:', error);
+ res.status(500).send({ error: 'Failed to read data file' });
+ }
+});
+
+// PUT endpoint to update data
+app.put('/data/weight-tracker-data.json', (req, res) => {
+ try {
+ const data = req.body;
+
+ // Validate data structure
+ if (!data || !data.weights || !data.meals) {
+ return res.status(400).send({ error: 'Invalid data structure' });
+ }
+
+ // Write to file
+ fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
+ res.send({ success: true, message: 'Data saved successfully' });
+
+ console.log(`Data updated: ${new Date().toISOString()}`);
+ } catch (error) {
+ console.error('Error writing data file:', error);
+ res.status(500).send({ error: 'Failed to write data file' });
+ }
+});
+
+// Start server
+app.listen(port, () => {
+ console.log(`Data API server running on port ${port}`);
+ console.log(`Data directory: ${DATA_DIR}`);
+ console.log(`Data file: ${DATA_FILE}`);
+});
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..5d78bc8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,23 @@
+version: '3.8'
+
+services:
+ weight-tracker:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: weight-tracker
+ restart: unless-stopped
+ ports:
+ - "8080:80"
+ volumes:
+ - weight-tracker-data:/data
+ networks:
+ - weight-tracker-network
+
+networks:
+ weight-tracker-network:
+ driver: bridge
+
+volumes:
+ weight-tracker-data:
+ driver: local
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..2fdde75
--- /dev/null
+++ b/index.html
@@ -0,0 +1,208 @@
+
+
+
+
+
+ Keep My Weight - Personal Weight & Meal Tracker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Weight History
+
+
+
+
+ | Date |
+ Weight (kg) |
+ Notes |
+ Actions |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Add Meal Entries
+
+
+
+
+
Meal History
+
+
+
+
+ | Date |
+ Breakfast |
+ Lunch |
+ Dinner |
+ Other |
+ Actions |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Weight Progress
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Add weight entries to see your progress chart
+
+
+
+
+
Export Data
+
+
+
+
+
+
+
+
+
+
+
+
+
About
+
Keep My Weight is a minimalist, privacy-first weight and meal tracking application.
+
+ - All your data stays on your device
+ - No account or registration required
+ - Simple, distraction-free interface
+
+
Version: 1.0.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/app.js b/js/app.js
new file mode 100644
index 0000000..a0421bf
--- /dev/null
+++ b/js/app.js
@@ -0,0 +1,16 @@
+/**
+ * Main Application Module
+ * Entry point for the application, initializes all components
+ */
+document.addEventListener('DOMContentLoaded', () => {
+ // Initialize all modules
+ DataManager.init();
+ UI.init();
+ Charts.init();
+
+ // Render initial data
+ UI.renderWeightTable();
+ UI.renderMealTable();
+
+ console.log('Weight Tracker app initialized successfully');
+});
diff --git a/js/charts.js b/js/charts.js
new file mode 100644
index 0000000..1c22c2e
--- /dev/null
+++ b/js/charts.js
@@ -0,0 +1,181 @@
+/**
+ * Charts Module
+ * Handles data visualization and charts
+ */
+const Charts = (() => {
+ // Chart configuration
+ let weightChartInstance = null;
+ let dateFilter = {
+ startDate: null,
+ endDate: null
+ };
+
+ /**
+ * Initialize charts
+ */
+ const init = () => {
+ // Load Chart.js from CDN if not available
+ if (typeof Chart === 'undefined') {
+ loadChartJS().then(() => {
+ renderWeightChart();
+ }).catch(error => {
+ console.error('Failed to load Chart.js:', error);
+ showChartError('Failed to load chart library');
+ });
+ } else {
+ renderWeightChart();
+ }
+ };
+
+ /**
+ * Load Chart.js and required adapters from CDN
+ * @returns {Promise} - Resolves when Chart.js is loaded
+ */
+ const loadChartJS = () => {
+ return new Promise((resolve, reject) => {
+ // First load Chart.js core
+ const chartScript = document.createElement('script');
+ chartScript.src = 'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js';
+ chartScript.crossOrigin = 'anonymous';
+
+ chartScript.onload = () => {
+ // Then load the date adapter
+ const adapterScript = document.createElement('script');
+ adapterScript.src = 'https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@2.0.0/dist/chartjs-adapter-date-fns.bundle.min.js';
+ adapterScript.crossOrigin = 'anonymous';
+
+ adapterScript.onload = () => resolve();
+ adapterScript.onerror = () => reject(new Error('Failed to load Chart.js date adapter'));
+
+ document.head.appendChild(adapterScript);
+ };
+
+ chartScript.onerror = () => reject(new Error('Failed to load Chart.js'));
+ document.head.appendChild(chartScript);
+ });
+ };
+
+ /**
+ * Render weight chart
+ */
+ const renderWeightChart = () => {
+ if (typeof Chart === 'undefined') {
+ return;
+ }
+
+ const chartContainer = document.getElementById('weight-chart');
+
+ // Get weight data with filters applied
+ const weights = DataManager.getWeights(dateFilter);
+
+ if (weights.length === 0) {
+ chartContainer.innerHTML = 'No weight data available for the selected period
';
+ return;
+ }
+
+ // Sort by date (oldest first for chart)
+ const sortedWeights = [...weights].sort((a, b) => new Date(a.date) - new Date(b.date));
+
+ // Prepare data for the chart
+ const labels = sortedWeights.map(entry => entry.date);
+ const data = sortedWeights.map(entry => entry.weight);
+
+ // Clear existing chart
+ if (weightChartInstance) {
+ weightChartInstance.destroy();
+ }
+
+ // Create canvas element
+ chartContainer.innerHTML = '';
+ const ctx = document.getElementById('weight-chart-canvas').getContext('2d');
+
+ // Create new chart
+ weightChartInstance = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: [{
+ label: 'Weight (kg)',
+ data: data,
+ fill: false,
+ borderColor: '#4a6fa5',
+ tension: 0.1,
+ pointBackgroundColor: '#4a6fa5',
+ pointRadius: 5,
+ pointHoverRadius: 7
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ x: {
+ type: 'time',
+ time: {
+ unit: 'day',
+ displayFormats: {
+ day: 'MMM d'
+ }
+ },
+ title: {
+ display: true,
+ text: 'Date'
+ }
+ },
+ y: {
+ title: {
+ display: true,
+ text: 'Weight (kg)'
+ },
+ beginAtZero: false
+ }
+ },
+ plugins: {
+ legend: {
+ display: true,
+ position: 'top'
+ },
+ tooltip: {
+ callbacks: {
+ title: function(tooltipItems) {
+ const date = new Date(tooltipItems[0].label);
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ });
+ }
+ }
+ }
+ }
+ }
+ });
+ };
+
+ /**
+ * Update the date filter and refresh the chart
+ * @param {string} startDate - Start date in YYYY-MM-DD format
+ * @param {string} endDate - End date in YYYY-MM-DD format
+ */
+ const updateDateFilter = (startDate, endDate) => {
+ dateFilter.startDate = startDate || null;
+ dateFilter.endDate = endDate || null;
+ renderWeightChart();
+ };
+
+ /**
+ * Show error message in chart container
+ * @param {string} message - Error message
+ */
+ const showChartError = (message) => {
+ const chartContainer = document.getElementById('weight-chart');
+ chartContainer.innerHTML = `${message}
`;
+ };
+
+ // Return public API
+ return {
+ init,
+ renderWeightChart,
+ updateDateFilter
+ };
+})();
diff --git a/js/dataManager.js b/js/dataManager.js
new file mode 100644
index 0000000..cf03a32
--- /dev/null
+++ b/js/dataManager.js
@@ -0,0 +1,443 @@
+/**
+ * Data Manager Module
+ * Handles all data operations including loading, saving, importing, and exporting data
+ * Supports both localStorage (for development) and server-side storage (for Docker deployment)
+ */
+const DataManager = (() => {
+ // Default empty data structure
+ const defaultData = {
+ weights: [],
+ meals: [],
+ version: '1.0.0'
+ };
+
+ // Current application data
+ let appData = {...defaultData};
+
+ // Determine if we're running in Docker (has /data endpoint)
+ const isDockerEnvironment = () => {
+ // Check if we're in a deployment environment with the /data endpoint
+ return window.location.hostname !== 'localhost' &&
+ window.location.hostname !== '127.0.0.1';
+ };
+
+ // Storage file path for Docker environment
+ const serverDataPath = '/data/weight-tracker-data.json';
+
+ /**
+ * Initialize data - load from server if in Docker, otherwise use localStorage
+ */
+ const init = async () => {
+ try {
+ if (isDockerEnvironment()) {
+ // Try to load from server-side storage
+ try {
+ const response = await fetch(serverDataPath);
+ if (response.ok) {
+ appData = await response.json();
+ console.log('Data loaded from server storage');
+ } else {
+ console.log('No server data found. Starting with empty data.');
+ appData = {...defaultData};
+ await saveData(); // Save default data to server
+ }
+ } catch (serverError) {
+ console.warn('Error loading from server, falling back to localStorage:', serverError);
+ loadFromLocalStorage();
+ }
+ } else {
+ // Use localStorage in development environment
+ loadFromLocalStorage();
+ }
+ } catch (error) {
+ console.error('Error initializing data:', error);
+ appData = {...defaultData};
+ saveData(); // Save default data structure on error
+ }
+ };
+
+ /**
+ * Load data from localStorage (used in development or as fallback)
+ */
+ const loadFromLocalStorage = () => {
+ const savedData = localStorage.getItem('weightTrackerData');
+ if (savedData) {
+ appData = JSON.parse(savedData);
+ console.log('Data loaded from localStorage');
+ } else {
+ console.log('No existing data found in localStorage. Starting with empty data.');
+ appData = {...defaultData};
+ saveDataToLocalStorage(); // Save default data structure
+ }
+ };
+
+ /**
+ * Save data to either server (in Docker) or localStorage (in development)
+ */
+ const saveData = async () => {
+ if (isDockerEnvironment()) {
+ return saveDataToServer();
+ } else {
+ return saveDataToLocalStorage();
+ }
+ };
+
+ /**
+ * Save data to localStorage (development environment)
+ */
+ const saveDataToLocalStorage = () => {
+ try {
+ localStorage.setItem('weightTrackerData', JSON.stringify(appData));
+ console.log('Data saved to localStorage');
+ return true;
+ } catch (error) {
+ console.error('Error saving data to localStorage:', error);
+ return false;
+ }
+ };
+
+ /**
+ * Save data to server (Docker environment)
+ */
+ const saveDataToServer = async () => {
+ try {
+ const response = await fetch(serverDataPath, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(appData)
+ });
+
+ if (response.ok) {
+ console.log('Data saved to server storage');
+ return true;
+ } else {
+ console.error('Error saving data to server:', response.statusText);
+ return saveDataToLocalStorage(); // Fallback to localStorage
+ }
+ } catch (error) {
+ console.error('Error saving data to server:', error);
+ return saveDataToLocalStorage(); // Fallback to localStorage
+ }
+ };
+
+ /**
+ * Add a weight entry
+ * @param {Object} entry - Weight entry {date, weight, notes}
+ */
+ const addWeight = (entry) => {
+ // Create an entry with a unique ID
+ const newEntry = {
+ id: generateId(),
+ date: entry.date,
+ weight: parseFloat(entry.weight),
+ notes: entry.notes || '',
+ createdAt: new Date().toISOString()
+ };
+
+ appData.weights.push(newEntry);
+ appData.weights.sort((a, b) => new Date(b.date) - new Date(a.date)); // Sort by date, newest first
+ return saveData();
+ };
+
+ /**
+ * Delete a weight entry
+ * @param {string} id - Entry ID
+ */
+ const deleteWeight = (id) => {
+ appData.weights = appData.weights.filter(entry => entry.id !== id);
+ return saveData();
+ };
+
+ /**
+ * Update a weight entry
+ * @param {Object} entry - Updated entry data
+ */
+ const updateWeight = (entry) => {
+ const index = appData.weights.findIndex(item => item.id === entry.id);
+ if (index !== -1) {
+ appData.weights[index] = {
+ ...appData.weights[index],
+ date: entry.date,
+ weight: parseFloat(entry.weight),
+ notes: entry.notes || '',
+ updatedAt: new Date().toISOString()
+ };
+ appData.weights.sort((a, b) => new Date(b.date) - new Date(a.date));
+ return saveData();
+ }
+ return false;
+ };
+
+ /**
+ * Get all weight entries
+ * @param {Object} filters - Optional filters {startDate, endDate}
+ * @returns {Array} - Filtered weight entries
+ */
+ const getWeights = (filters = {}) => {
+ let results = [...appData.weights];
+
+ if (filters.startDate) {
+ results = results.filter(entry => new Date(entry.date) >= new Date(filters.startDate));
+ }
+
+ if (filters.endDate) {
+ results = results.filter(entry => new Date(entry.date) <= new Date(filters.endDate));
+ }
+
+ return results;
+ };
+
+ /**
+ * Add a meal entry
+ * @param {Object} entry - Meal entry {date, breakfast, lunch, dinner, otherMeals}
+ */
+ const addMeal = (entry) => {
+ // Check if a meal entry for this date already exists
+ const existingIndex = appData.meals.findIndex(meal => meal.date === entry.date);
+
+ if (existingIndex !== -1) {
+ // Update existing entry
+ appData.meals[existingIndex] = {
+ ...appData.meals[existingIndex],
+ breakfast: entry.breakfast || '',
+ lunch: entry.lunch || '',
+ dinner: entry.dinner || '',
+ otherMeals: entry.otherMeals || '',
+ updatedAt: new Date().toISOString()
+ };
+ } else {
+ // Create new entry
+ const newEntry = {
+ id: generateId(),
+ date: entry.date,
+ breakfast: entry.breakfast || '',
+ lunch: entry.lunch || '',
+ dinner: entry.dinner || '',
+ otherMeals: entry.otherMeals || '',
+ createdAt: new Date().toISOString()
+ };
+
+ appData.meals.push(newEntry);
+ }
+
+ appData.meals.sort((a, b) => new Date(b.date) - new Date(a.date)); // Sort by date, newest first
+ return saveData();
+ };
+
+ /**
+ * Delete a meal entry
+ * @param {string} id - Entry ID
+ */
+ const deleteMeal = (id) => {
+ appData.meals = appData.meals.filter(entry => entry.id !== id);
+ return saveData();
+ };
+
+ /**
+ * Get all meal entries
+ * @param {Object} filters - Optional filters {startDate, endDate}
+ * @returns {Array} - Filtered meal entries
+ */
+ const getMeals = (filters = {}) => {
+ let results = [...appData.meals];
+
+ if (filters.startDate) {
+ results = results.filter(entry => new Date(entry.date) >= new Date(filters.startDate));
+ }
+
+ if (filters.endDate) {
+ results = results.filter(entry => new Date(entry.date) <= new Date(filters.endDate));
+ }
+
+ return results;
+ };
+
+ /**
+ * Export all data as a JSON file
+ */
+ const exportData = () => {
+ try {
+ const dataStr = JSON.stringify(appData, null, 2);
+
+ // Create a blob instead of using data URI
+ const blob = new Blob([dataStr], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+
+ const exportFileDefaultName = `weight-tracker-backup-${formatDateForFilename(new Date())}.json`;
+
+ const linkElement = document.createElement('a');
+ linkElement.setAttribute('href', url);
+ linkElement.setAttribute('download', exportFileDefaultName);
+ linkElement.style.display = 'none';
+
+ // Add to DOM, trigger click, and clean up
+ document.body.appendChild(linkElement);
+ linkElement.click();
+
+ // Clean up
+ setTimeout(() => {
+ document.body.removeChild(linkElement);
+ URL.revokeObjectURL(url);
+ }, 100);
+
+ return true;
+ } catch (error) {
+ console.error('Error exporting data:', error);
+ return false;
+ }
+ };
+
+ /**
+ * Import data from a JSON file
+ * @param {Object} importedData - Data to import
+ * @param {string} mode - 'merge' or 'overwrite'
+ * @returns {boolean} - Success status
+ */
+ const importData = (importedData, mode = 'merge') => {
+ try {
+ // Validate imported data has the required structure
+ if (!importedData || !importedData.weights || !Array.isArray(importedData.weights) ||
+ !importedData.meals || !Array.isArray(importedData.meals)) {
+ console.error('Invalid data structure in imported file');
+ return false;
+ }
+
+ if (mode === 'overwrite') {
+ // Overwrite all data
+ appData = {
+ ...importedData,
+ version: importedData.version || '1.0.0' // Ensure version exists
+ };
+ } else {
+ // Merge data - combine arrays and remove duplicates by ID
+ const mergedWeights = [...appData.weights];
+ const mergedMeals = [...appData.meals];
+
+ // Process imported weights
+ importedData.weights.forEach(importedWeight => {
+ const existingIndex = mergedWeights.findIndex(w => w.id === importedWeight.id);
+ if (existingIndex !== -1) {
+ mergedWeights[existingIndex] = importedWeight; // Replace with imported
+ } else {
+ mergedWeights.push(importedWeight); // Add new entry
+ }
+ });
+
+ // Process imported meals
+ importedData.meals.forEach(importedMeal => {
+ const existingIndex = mergedMeals.findIndex(m => m.id === importedMeal.id);
+ if (existingIndex !== -1) {
+ mergedMeals[existingIndex] = importedMeal; // Replace with imported
+ } else {
+ mergedMeals.push(importedMeal); // Add new entry
+ }
+ });
+
+ // Sort arrays by date
+ mergedWeights.sort((a, b) => new Date(b.date) - new Date(a.date));
+ mergedMeals.sort((a, b) => new Date(b.date) - new Date(a.date));
+
+ // Update app data
+ appData = {
+ weights: mergedWeights,
+ meals: mergedMeals,
+ version: appData.version // Keep current version
+ };
+ }
+
+ return saveData();
+ } catch (error) {
+ console.error('Error importing data:', error);
+ return false;
+ }
+ };
+
+ /**
+ * Export data as CSV
+ * @param {string} type - 'weights' or 'meals'
+ */
+ const exportCSV = (type) => {
+ try {
+ let csvContent = '';
+ let filename = '';
+
+ if (type === 'weights') {
+ // CSV Header
+ csvContent = 'Date,Weight (kg),Notes\n';
+
+ // Add data rows
+ appData.weights.forEach(entry => {
+ const notes = entry.notes ? `"${entry.notes.replace(/"/g, '""')}"` : '';
+ csvContent += `${entry.date},${entry.weight},${notes}\n`;
+ });
+
+ filename = `weight-data-${formatDateForFilename(new Date())}.csv`;
+ }
+ else if (type === 'meals') {
+ // CSV Header
+ csvContent = 'Date,Breakfast,Lunch,Dinner,Other\n';
+
+ // Add data rows
+ appData.meals.forEach(entry => {
+ const breakfast = entry.breakfast ? `"${entry.breakfast.replace(/"/g, '""')}"` : '';
+ const lunch = entry.lunch ? `"${entry.lunch.replace(/"/g, '""')}"` : '';
+ const dinner = entry.dinner ? `"${entry.dinner.replace(/"/g, '""')}"` : '';
+ const other = entry.otherMeals ? `"${entry.otherMeals.replace(/"/g, '""')}"` : '';
+
+ csvContent += `${entry.date},${breakfast},${lunch},${dinner},${other}\n`;
+ });
+
+ filename = `meal-data-${formatDateForFilename(new Date())}.csv`;
+ }
+
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
+ const link = document.createElement('a');
+
+ const url = URL.createObjectURL(blob);
+ link.setAttribute('href', url);
+ link.setAttribute('download', filename);
+ link.style.visibility = 'hidden';
+
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ } catch (error) {
+ console.error('Error exporting CSV:', error);
+ }
+ };
+
+ /**
+ * Generate a unique ID for entries
+ * @returns {string} - Unique ID
+ */
+ const generateId = () => {
+ return Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
+ };
+
+ /**
+ * Format date for filename
+ * @param {Date} date - Date to format
+ * @returns {string} - Formatted date string (YYYY-MM-DD)
+ */
+ const formatDateForFilename = (date) => {
+ return date.toISOString().split('T')[0];
+ };
+
+ // Return public API
+ return {
+ init,
+ addWeight,
+ deleteWeight,
+ updateWeight,
+ getWeights,
+ addMeal,
+ deleteMeal,
+ getMeals,
+ exportData,
+ importData,
+ exportCSV,
+ getData: () => appData
+ };
+})();
diff --git a/js/ui.js b/js/ui.js
new file mode 100644
index 0000000..91612ed
--- /dev/null
+++ b/js/ui.js
@@ -0,0 +1,471 @@
+/**
+ * UI Module
+ * Handles all user interface interactions and rendering
+ */
+const UI = (() => {
+ // Tab elements
+ const tabs = {
+ weight: { btn: document.getElementById('tab-weight'), content: document.getElementById('weight-log-content') },
+ meals: { btn: document.getElementById('tab-meals'), content: document.getElementById('meals-log-content') },
+ charts: { btn: document.getElementById('tab-charts'), content: document.getElementById('charts-content') },
+ settings: { btn: document.getElementById('tab-settings'), content: document.getElementById('settings-content') }
+ };
+
+ // Form elements
+ const forms = {
+ weight: document.getElementById('weight-form'),
+ meal: document.getElementById('meal-form')
+ };
+
+ // Table elements
+ const tables = {
+ weight: document.getElementById('weight-entries'),
+ meal: document.getElementById('meal-entries')
+ };
+
+ // Initialize the UI
+ const init = () => {
+ // Set today's date as default for forms
+ document.getElementById('weight-date').value = Utils.getTodayDate();
+ document.getElementById('meal-date').value = Utils.getTodayDate();
+
+ // Initialize tab navigation
+ initTabs();
+
+ // Initialize form submissions
+ initForms();
+
+ // Initialize data import/export
+ initDataManagement();
+
+ // Add notifications styles
+ addNotificationsCSS();
+ };
+
+ /**
+ * Initialize tab navigation
+ */
+ const initTabs = () => {
+ // Add click event to each tab button
+ for (const tabKey in tabs) {
+ const tab = tabs[tabKey];
+ tab.btn.addEventListener('click', () => switchTab(tabKey));
+ }
+ };
+
+ /**
+ * Switch active tab
+ * @param {string} tabKey - Key of the tab to activate
+ */
+ const switchTab = (tabKey) => {
+ // Deactivate all tabs
+ for (const key in tabs) {
+ tabs[key].btn.classList.remove('active');
+ tabs[key].content.classList.remove('active');
+ }
+
+ // Activate the selected tab
+ tabs[tabKey].btn.classList.add('active');
+ tabs[tabKey].content.classList.add('active');
+ };
+
+ /**
+ * Initialize form submissions
+ */
+ const initForms = () => {
+ // Weight form submission
+ forms.weight.addEventListener('submit', (e) => {
+ e.preventDefault();
+
+ const weightEntry = {
+ date: document.getElementById('weight-date').value,
+ weight: document.getElementById('weight-value').value,
+ notes: document.getElementById('weight-notes').value
+ };
+
+ // Validate entry
+ const validation = Utils.validateWeightEntry(weightEntry);
+ if (!validation.valid) {
+ Utils.showNotification(validation.message, 'error');
+ return;
+ }
+
+ // Add entry to data
+ if (DataManager.addWeight(weightEntry)) {
+ Utils.showNotification('Weight entry saved successfully', 'success');
+
+ // Reset form
+ document.getElementById('weight-date').value = Utils.getTodayDate();
+ document.getElementById('weight-value').value = '';
+ document.getElementById('weight-notes').value = '';
+
+ // Refresh table
+ renderWeightTable();
+
+ // Update charts if on chart tab
+ if (tabs.charts.content.classList.contains('active')) {
+ Charts.renderWeightChart();
+ }
+ } else {
+ Utils.showNotification('Error saving weight entry', 'error');
+ }
+ });
+
+ // Meal form submission
+ forms.meal.addEventListener('submit', (e) => {
+ e.preventDefault();
+
+ const mealEntry = {
+ date: document.getElementById('meal-date').value,
+ breakfast: document.getElementById('breakfast').value,
+ lunch: document.getElementById('lunch').value,
+ dinner: document.getElementById('dinner').value,
+ otherMeals: document.getElementById('other-meals').value
+ };
+
+ // Add/update entry
+ if (DataManager.addMeal(mealEntry)) {
+ Utils.showNotification('Meal entries saved successfully', 'success');
+
+ // Reset form except for date
+ document.getElementById('breakfast').value = '';
+ document.getElementById('lunch').value = '';
+ document.getElementById('dinner').value = '';
+ document.getElementById('other-meals').value = '';
+
+ // Refresh table
+ renderMealTable();
+ } else {
+ Utils.showNotification('Error saving meal entries', 'error');
+ }
+ });
+ };
+
+ /**
+ * Initialize data import/export functionality
+ */
+ const initDataManagement = () => {
+ // Export data as JSON
+ document.getElementById('export-data').addEventListener('click', () => {
+ DataManager.exportData();
+ Utils.showNotification('Data exported successfully', 'success');
+ });
+
+ // Import data file selection
+ document.getElementById('import-data-file').addEventListener('change', (e) => {
+ const fileInput = e.target;
+ const importButton = document.getElementById('import-data');
+
+ if (fileInput.files.length > 0) {
+ document.getElementById('file-name-display').textContent = fileInput.files[0].name;
+ importButton.disabled = false;
+ } else {
+ document.getElementById('file-name-display').textContent = 'No file selected';
+ importButton.disabled = true;
+ }
+ });
+
+ // Import data from JSON
+ document.getElementById('import-data').addEventListener('click', () => {
+ const fileInput = document.getElementById('import-data-file');
+ const importMode = document.querySelector('input[name="import-mode"]:checked').value;
+
+ if (fileInput.files.length === 0) {
+ Utils.showNotification('Please select a file to import', 'error');
+ return;
+ }
+
+ const file = fileInput.files[0];
+ const reader = new FileReader();
+
+ reader.onload = (event) => {
+ try {
+ // First, validate that we can parse the JSON
+ let importedData;
+ try {
+ importedData = JSON.parse(event.target.result);
+ } catch (parseError) {
+ console.error('JSON parsing error:', parseError);
+ Utils.showNotification('Invalid JSON format. Please check the file contents.', 'error');
+ return;
+ }
+
+ // Then validate the structure before importing
+ if (!importedData || typeof importedData !== 'object') {
+ Utils.showNotification('Invalid data: File does not contain a valid data object', 'error');
+ return;
+ }
+
+ // Check for weights and meals arrays
+ if (!importedData.weights || !Array.isArray(importedData.weights)) {
+ Utils.showNotification('Invalid data: Missing weights array', 'error');
+ return;
+ }
+
+ if (!importedData.meals || !Array.isArray(importedData.meals)) {
+ Utils.showNotification('Invalid data: Missing meals array', 'error');
+ return;
+ }
+
+ // Try to import the data
+ if (DataManager.importData(importedData, importMode)) {
+ Utils.showNotification('Data imported successfully', 'success');
+
+ // Refresh tables and charts
+ renderWeightTable();
+ renderMealTable();
+ Charts.renderWeightChart();
+
+ // Reset file input
+ fileInput.value = '';
+ document.getElementById('file-name-display').textContent = 'No file selected';
+ document.getElementById('import-data').disabled = true;
+ } else {
+ Utils.showNotification('Error importing data. Please check console for details.', 'error');
+ }
+ } catch (error) {
+ console.error('Error processing imported data:', error);
+ Utils.showNotification('Error processing data: ' + (error.message || 'Unknown error'), 'error');
+ }
+ };
+
+ reader.onerror = () => {
+ Utils.showNotification('Error reading file', 'error');
+ };
+
+ reader.readAsText(file);
+ });
+
+ // Export as CSV
+ document.getElementById('export-csv').addEventListener('click', () => {
+ DataManager.exportCSV('weights');
+ Utils.showNotification('Weight data exported as CSV', 'success');
+ });
+
+ // Copy table data to clipboard
+ document.getElementById('copy-table-data').addEventListener('click', () => {
+ const weights = DataManager.getWeights();
+ if (weights.length === 0) {
+ Utils.showNotification('No weight data to copy', 'error');
+ return;
+ }
+
+ let csvContent = 'Date,Weight (kg),Notes\n';
+
+ weights.forEach(entry => {
+ const notes = entry.notes ? `"${entry.notes.replace(/"/g, '""')}"` : '';
+ csvContent += `${entry.date},${entry.weight},${notes}\n`;
+ });
+
+ if (Utils.copyToClipboard(csvContent)) {
+ Utils.showNotification('Weight data copied to clipboard', 'success');
+ } else {
+ Utils.showNotification('Failed to copy data to clipboard', 'error');
+ }
+ });
+
+ // Date filter for chart
+ document.getElementById('apply-date-filter').addEventListener('click', () => {
+ Charts.updateDateFilter(
+ document.getElementById('chart-start-date').value,
+ document.getElementById('chart-end-date').value
+ );
+ });
+
+ // Reset date filter
+ document.getElementById('reset-date-filter').addEventListener('click', () => {
+ document.getElementById('chart-start-date').value = '';
+ document.getElementById('chart-end-date').value = '';
+ Charts.updateDateFilter(null, null);
+ });
+ };
+
+ /**
+ * Render the weight entries table
+ */
+ const renderWeightTable = () => {
+ const weights = DataManager.getWeights();
+ tables.weight.innerHTML = '';
+
+ if (weights.length === 0) {
+ // Show empty state
+ const emptyRow = document.createElement('tr');
+ emptyRow.innerHTML = `
+
No weight entries found. Use the form above to add your first entry. |
+ `;
+ tables.weight.appendChild(emptyRow);
+ return;
+ }
+
+ // Render each weight entry
+ weights.forEach(entry => {
+ const row = document.createElement('tr');
+ row.innerHTML = `
+ ${Utils.formatDate(entry.date)} |
+ ${entry.weight} kg |
+ ${entry.notes} |
+
+
+ |
+ `;
+ tables.weight.appendChild(row);
+ });
+
+ // Add event listeners for delete buttons
+ document.querySelectorAll('.delete-weight').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const id = e.target.getAttribute('data-id');
+ if (confirm('Are you sure you want to delete this weight entry?')) {
+ if (DataManager.deleteWeight(id)) {
+ Utils.showNotification('Weight entry deleted', 'success');
+ renderWeightTable();
+ Charts.renderWeightChart();
+ } else {
+ Utils.showNotification('Error deleting weight entry', 'error');
+ }
+ }
+ });
+ });
+ };
+
+ /**
+ * Render the meal entries table
+ */
+ const renderMealTable = () => {
+ const meals = DataManager.getMeals();
+ tables.meal.innerHTML = '';
+
+ if (meals.length === 0) {
+ // Show empty state
+ const emptyRow = document.createElement('tr');
+ emptyRow.innerHTML = `
+ No meal entries found. Use the form above to add your first entry. |
+ `;
+ tables.meal.appendChild(emptyRow);
+ return;
+ }
+
+ // Render each meal entry
+ meals.forEach(entry => {
+ const row = document.createElement('tr');
+ row.innerHTML = `
+ ${Utils.formatDate(entry.date)} |
+ ${Utils.truncateText(entry.breakfast, 30)} |
+ ${Utils.truncateText(entry.lunch, 30)} |
+ ${Utils.truncateText(entry.dinner, 30)} |
+ ${Utils.truncateText(entry.otherMeals, 30)} |
+
+
+
+ |
+ `;
+ tables.meal.appendChild(row);
+ });
+
+ // Add event listeners for delete buttons
+ document.querySelectorAll('.delete-meal').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const id = e.target.getAttribute('data-id');
+ if (confirm('Are you sure you want to delete this meal entry?')) {
+ if (DataManager.deleteMeal(id)) {
+ Utils.showNotification('Meal entry deleted', 'success');
+ renderMealTable();
+ } else {
+ Utils.showNotification('Error deleting meal entry', 'error');
+ }
+ }
+ });
+ });
+
+ // Add event listeners for edit buttons
+ document.querySelectorAll('.edit-meal').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ const id = e.target.getAttribute('data-id');
+ const meal = DataManager.getMeals().find(m => m.id === id);
+
+ if (meal) {
+ document.getElementById('meal-date').value = meal.date;
+ document.getElementById('breakfast').value = meal.breakfast || '';
+ document.getElementById('lunch').value = meal.lunch || '';
+ document.getElementById('dinner').value = meal.dinner || '';
+ document.getElementById('other-meals').value = meal.otherMeals || '';
+
+ // Scroll to form
+ document.getElementById('meal-form').scrollIntoView({ behavior: 'smooth' });
+
+ Utils.showNotification('Edit meal entry and click Save to update', 'info');
+ }
+ });
+ });
+ };
+
+ /**
+ * Add CSS for notifications
+ */
+ const addNotificationsCSS = () => {
+ // Create style element
+ const style = document.createElement('style');
+ style.textContent = `
+ .notification {
+ position: fixed;
+ bottom: -60px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 12px 25px;
+ border-radius: var(--border-radius);
+ background-color: #333;
+ color: white;
+ font-weight: 500;
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
+ z-index: 1000;
+ transition: bottom 0.3s ease-in-out;
+ }
+
+ .notification.show {
+ bottom: 30px;
+ }
+
+ .notification-success {
+ background-color: var(--success-color);
+ }
+
+ .notification-error {
+ background-color: var(--error-color);
+ }
+
+ .notification-info {
+ background-color: var(--primary-color);
+ }
+
+ .empty-state {
+ text-align: center;
+ padding: 20px;
+ color: var(--text-secondary);
+ font-style: italic;
+ }
+
+ @media (max-width: 768px) {
+ .notification {
+ width: 90%;
+ padding: 10px 15px;
+ font-size: 0.9rem;
+ }
+
+ .notification.show {
+ bottom: 20px;
+ }
+ }
+ `;
+
+ // Add to document head
+ document.head.appendChild(style);
+ };
+
+ // Return public API
+ return {
+ init,
+ renderWeightTable,
+ renderMealTable,
+ switchTab
+ };
+})();
diff --git a/js/utils.js b/js/utils.js
new file mode 100644
index 0000000..1659d5b
--- /dev/null
+++ b/js/utils.js
@@ -0,0 +1,168 @@
+/**
+ * Utilities Module
+ * Contains helper functions used across the application
+ */
+const Utils = (() => {
+ /**
+ * Format a date string (YYYY-MM-DD) to a more readable format
+ * @param {string} dateString - Date in YYYY-MM-DD format
+ * @returns {string} - Formatted date (e.g., "May 26, 2025")
+ */
+ const formatDate = (dateString) => {
+ if (!dateString) return '';
+ const date = new Date(dateString);
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ });
+ };
+
+ /**
+ * Get today's date in YYYY-MM-DD format
+ * @returns {string} - Today's date
+ */
+ const getTodayDate = () => {
+ const today = new Date();
+ return today.toISOString().split('T')[0];
+ };
+
+ /**
+ * Truncate text to a specific length
+ * @param {string} text - Text to truncate
+ * @param {number} maxLength - Maximum length
+ * @returns {string} - Truncated text with ellipsis if needed
+ */
+ const truncateText = (text, maxLength = 50) => {
+ if (!text) return '';
+ if (text.length <= maxLength) return text;
+ return text.substring(0, maxLength) + '...';
+ };
+
+ /**
+ * Show a notification/toast message
+ * @param {string} message - Message to display
+ * @param {string} type - Type of message (success, error, info)
+ */
+ const showNotification = (message, type = 'info') => {
+ // Create notification element
+ const notification = document.createElement('div');
+ notification.classList.add('notification', `notification-${type}`);
+ notification.textContent = message;
+
+ // Add to DOM
+ document.body.appendChild(notification);
+
+ // Trigger animation
+ setTimeout(() => {
+ notification.classList.add('show');
+ }, 10);
+
+ // Remove after delay
+ setTimeout(() => {
+ notification.classList.remove('show');
+ setTimeout(() => {
+ notification.remove();
+ }, 300);
+ }, 3000);
+ };
+
+ /**
+ * Validate a weight entry
+ * @param {Object} entry - Weight entry to validate
+ * @returns {Object} - Validation result {valid, message}
+ */
+ const validateWeightEntry = (entry) => {
+ if (!entry.date) {
+ return { valid: false, message: 'Date is required' };
+ }
+
+ if (!entry.weight) {
+ return { valid: false, message: 'Weight value is required' };
+ }
+
+ const weightValue = parseFloat(entry.weight);
+ if (isNaN(weightValue) || weightValue <= 0) {
+ return { valid: false, message: 'Weight must be a positive number' };
+ }
+
+ return { valid: true, message: 'Valid entry' };
+ };
+
+ /**
+ * Calculate weight change and trend
+ * @param {Array} weights - Array of weight entries
+ * @returns {Object} - Statistics {totalChange, averageChange, trend}
+ */
+ const calculateWeightStats = (weights) => {
+ if (!weights || weights.length < 2) {
+ return { totalChange: 0, averageChange: 0, trend: 'neutral' };
+ }
+
+ // Sort by date (oldest first)
+ const sortedWeights = [...weights].sort((a, b) => new Date(a.date) - new Date(b.date));
+
+ const firstWeight = sortedWeights[0].weight;
+ const lastWeight = sortedWeights[sortedWeights.length - 1].weight;
+ const totalChange = lastWeight - firstWeight;
+
+ // Calculate average change per day
+ const daysDiff = Math.max(1, (new Date(sortedWeights[sortedWeights.length - 1].date) - new Date(sortedWeights[0].date)) / (1000 * 60 * 60 * 24));
+ const averageChange = totalChange / daysDiff;
+
+ // Determine trend
+ let trend = 'neutral';
+ if (totalChange < 0) {
+ trend = 'down';
+ } else if (totalChange > 0) {
+ trend = 'up';
+ }
+
+ return {
+ totalChange: totalChange.toFixed(2),
+ averageChange: averageChange.toFixed(2),
+ trend
+ };
+ };
+
+ /**
+ * Copy text to clipboard
+ * @param {string} text - Text to copy
+ * @returns {boolean} - Success status
+ */
+ const copyToClipboard = (text) => {
+ try {
+ // Use the modern navigator.clipboard API if available
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text);
+ return true;
+ }
+
+ // Fallback for older browsers
+ const textArea = document.createElement('textarea');
+ textArea.value = text;
+ textArea.style.position = 'fixed';
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
+
+ const successful = document.execCommand('copy');
+ document.body.removeChild(textArea);
+ return successful;
+ } catch (error) {
+ console.error('Error copying to clipboard:', error);
+ return false;
+ }
+ };
+
+ // Return public API
+ return {
+ formatDate,
+ getTodayDate,
+ truncateText,
+ showNotification,
+ validateWeightEntry,
+ calculateWeightStats,
+ copyToClipboard
+ };
+})();
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..548ed0b
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,56 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Enable compression
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
+
+ # Serve static files directly
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Proxy requests to the data API
+ location /data/ {
+ proxy_pass http://localhost:3000/data/;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ }
+
+ # Enable browser caching for static assets
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
+ expires 30d;
+ add_header Cache-Control "public, no-transform";
+ }
+
+ # CORS headers
+ add_header 'Access-Control-Allow-Origin' '*' always;
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
+ add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
+
+ # Handle preflight requests
+ if ($request_method = 'OPTIONS') {
+ add_header 'Access-Control-Allow-Origin' '*';
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
+ add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization';
+ add_header 'Access-Control-Max-Age' 1728000;
+ add_header 'Content-Type' 'text/plain charset=UTF-8';
+ add_header 'Content-Length' 0;
+ return 204;
+ }
+
+ # Error pages
+ error_page 404 /index.html;
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d8ec9fc
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "weight-tracker",
+ "version": "1.0.0",
+ "description": "Minimalist weight and meal tracking application",
+ "main": "data-api.js",
+ "private": true,
+ "scripts": {
+ "start": "node data-api.js"
+ },
+ "dependencies": {
+ "express": "^4.18.2",
+ "body-parser": "^1.20.2",
+ "cors": "^2.8.5"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..f7adbc7
--- /dev/null
+++ b/server.js
@@ -0,0 +1,59 @@
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+// Port can be specified as command line argument or default to 8080
+const port = process.argv[2] || 8080;
+
+// MIME types for different file extensions
+const mimeTypes = {
+ '.html': 'text/html',
+ '.css': 'text/css',
+ '.js': 'text/javascript',
+ '.json': 'application/json',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.svg': 'image/svg+xml',
+ '.ico': 'image/x-icon'
+};
+
+// Create a simple HTTP server
+const server = http.createServer((req, res) => {
+ console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
+
+ // Handle root path
+ let filePath = req.url === '/'
+ ? path.join(__dirname, 'index.html')
+ : path.join(__dirname, req.url);
+
+ // Get the file extension
+ const extname = path.extname(filePath);
+
+ // Default content type
+ let contentType = mimeTypes[extname] || 'application/octet-stream';
+
+ // Read file and serve content
+ fs.readFile(filePath, (error, content) => {
+ if (error) {
+ if (error.code === 'ENOENT') {
+ // File not found
+ res.writeHead(404);
+ res.end('404 - File Not Found');
+ } else {
+ // Server error
+ res.writeHead(500);
+ res.end(`Server Error: ${error.code}`);
+ }
+ } else {
+ // Successful response
+ res.writeHead(200, { 'Content-Type': contentType });
+ res.end(content, 'utf-8');
+ }
+ });
+});
+
+// Start the server
+server.listen(port, () => {
+ console.log(`Server running at http://localhost:${port}/`);
+ console.log(`Press Ctrl+C to stop the server`);
+});
diff --git a/supervisord.conf b/supervisord.conf
new file mode 100644
index 0000000..d898e20
--- /dev/null
+++ b/supervisord.conf
@@ -0,0 +1,26 @@
+[supervisord]
+nodaemon=true
+user=root
+logfile=/dev/stdout
+logfile_maxbytes=0
+pidfile=/var/run/supervisord.pid
+
+[program:nginx]
+command=nginx -g 'daemon off;'
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+
+[program:data-api]
+command=node /usr/share/nginx/api/data-api.js
+directory=/usr/share/nginx/api
+environment=DATA_DIR="/data",PORT="3000"
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0