feat: initial project setup with core files and Docker configuration

This commit is contained in:
Greg 2025-05-26 23:08:42 +02:00
parent cd76cd5422
commit 9d73bcec50
16 changed files with 2352 additions and 1 deletions

101
.windsurfrules Normal file
View File

@ -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 <input type="file"> / <a download>
Server:
Containerized static file server
Serves index.html, static assets, and data.json

46
Dockerfile Normal file
View File

@ -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"]

View File

@ -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.

354
css/styles.css Normal file
View File

@ -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;
}
}

85
data-api.js Normal file
View File

@ -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}`);
});

23
docker-compose.yml Normal file
View File

@ -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

208
index.html Normal file
View File

@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keep My Weight - Personal Weight & Meal Tracker</title>
<link rel="stylesheet" href="css/styles.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚖️</text></svg>">
</head>
<body>
<div class="app-container">
<header>
<h1>Keep My Weight</h1>
<p class="tagline">Simple, private weight & meal tracking</p>
</header>
<main>
<section class="tabs">
<button id="tab-weight" class="tab-btn active">Weight Log</button>
<button id="tab-meals" class="tab-btn">Meal Log</button>
<button id="tab-charts" class="tab-btn">Progress</button>
<button id="tab-settings" class="tab-btn">Settings</button>
</section>
<!-- Weight Log Tab -->
<div id="weight-log-content" class="tab-content active">
<div class="card">
<h2>Add Weight Entry</h2>
<form id="weight-form">
<div class="form-group">
<label for="weight-date">Date</label>
<input type="date" id="weight-date" name="weight-date" required>
</div>
<div class="form-group">
<label for="weight-value">Weight (kg)</label>
<input type="number" id="weight-value" name="weight-value" step="0.1" min="20" max="500" required>
</div>
<div class="form-group">
<label for="weight-notes">Notes (optional)</label>
<textarea id="weight-notes" name="weight-notes" rows="2"></textarea>
</div>
<button type="submit" class="btn primary-btn">Save Weight</button>
</form>
</div>
<div class="card">
<h2>Weight History</h2>
<div class="table-container">
<table id="weight-table">
<thead>
<tr>
<th>Date</th>
<th>Weight (kg)</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="weight-entries">
<!-- Weight entries will be added here dynamically -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Meals Log Tab -->
<div id="meals-log-content" class="tab-content">
<div class="card">
<h2>Add Meal Entries</h2>
<form id="meal-form">
<div class="form-group">
<label for="meal-date">Date</label>
<input type="date" id="meal-date" name="meal-date" required>
</div>
<div class="meal-sections">
<div class="form-group">
<label for="breakfast">Breakfast</label>
<textarea id="breakfast" name="breakfast" rows="2"></textarea>
</div>
<div class="form-group">
<label for="lunch">Lunch</label>
<textarea id="lunch" name="lunch" rows="2"></textarea>
</div>
<div class="form-group">
<label for="dinner">Dinner</label>
<textarea id="dinner" name="dinner" rows="2"></textarea>
</div>
<div class="form-group">
<label for="other-meals">Other</label>
<textarea id="other-meals" name="other-meals" rows="2"></textarea>
</div>
</div>
<button type="submit" class="btn primary-btn">Save Meals</button>
</form>
</div>
<div class="card">
<h2>Meal History</h2>
<div class="table-container">
<table id="meal-table">
<thead>
<tr>
<th>Date</th>
<th>Breakfast</th>
<th>Lunch</th>
<th>Dinner</th>
<th>Other</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="meal-entries">
<!-- Meal entries will be added here dynamically -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Charts Tab -->
<div id="charts-content" class="tab-content">
<div class="card">
<h2>Weight Progress</h2>
<div class="chart-filters">
<div class="form-group">
<label for="chart-start-date">From</label>
<input type="date" id="chart-start-date">
</div>
<div class="form-group">
<label for="chart-end-date">To</label>
<input type="date" id="chart-end-date">
</div>
<button id="apply-date-filter" class="btn secondary-btn">Apply Filter</button>
<button id="reset-date-filter" class="btn text-btn">Reset</button>
</div>
<div id="weight-chart" class="chart-container">
<!-- Weight chart will be rendered here -->
<div class="placeholder-message">Add weight entries to see your progress chart</div>
</div>
</div>
<div class="card">
<h2>Export Data</h2>
<div class="btn-group">
<button id="export-csv" class="btn secondary-btn">Export as CSV</button>
<button id="copy-table-data" class="btn secondary-btn">Copy to Clipboard</button>
</div>
</div>
</div>
<!-- Settings Tab -->
<div id="settings-content" class="tab-content">
<div class="card">
<h2>Data Management</h2>
<div class="form-group">
<h3>Backup Data</h3>
<p>Download all your weight and meal data as a JSON file</p>
<button id="export-data" class="btn primary-btn">Export Data</button>
</div>
<div class="form-group">
<h3>Import Data</h3>
<p>Load previously exported data from a JSON file</p>
<div class="file-input-container">
<label for="import-data-file" class="btn primary-btn">Choose File</label>
<input type="file" id="import-data-file" accept=".json">
<span id="file-name-display">No file selected</span>
</div>
<div class="import-options">
<label class="radio-container">
<input type="radio" name="import-mode" value="merge" checked>
<span class="radio-text">Merge with existing data</span>
</label>
<label class="radio-container">
<input type="radio" name="import-mode" value="overwrite">
<span class="radio-text">Overwrite existing data</span>
</label>
</div>
<button id="import-data" class="btn secondary-btn" disabled>Import</button>
</div>
</div>
<div class="card">
<h2>About</h2>
<p>Keep My Weight is a minimalist, privacy-first weight and meal tracking application.</p>
<ul class="features-list">
<li>All your data stays on your device</li>
<li>No account or registration required</li>
<li>Simple, distraction-free interface</li>
</ul>
<p><strong>Version:</strong> 1.0.0</p>
</div>
</div>
</main>
<footer>
<p>&copy; 2025 Keep My Weight. Your data stays private, always.</p>
</footer>
</div>
<!-- JavaScript -->
<script src="js/utils.js"></script>
<script src="js/dataManager.js"></script>
<script src="js/ui.js"></script>
<script src="js/charts.js"></script>
<script src="js/app.js"></script>
</body>
</html>

16
js/app.js Normal file
View File

@ -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');
});

181
js/charts.js Normal file
View File

@ -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 = '<div class="placeholder-message">No weight data available for the selected period</div>';
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 = '<canvas id="weight-chart-canvas"></canvas>';
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 = `<div class="placeholder-message error">${message}</div>`;
};
// Return public API
return {
init,
renderWeightChart,
updateDateFilter
};
})();

443
js/dataManager.js Normal file
View File

@ -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
};
})();

471
js/ui.js Normal file
View File

@ -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 = `
<td colspan="4" class="empty-state">No weight entries found. Use the form above to add your first entry.</td>
`;
tables.weight.appendChild(emptyRow);
return;
}
// Render each weight entry
weights.forEach(entry => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${Utils.formatDate(entry.date)}</td>
<td>${entry.weight} kg</td>
<td>${entry.notes}</td>
<td>
<button class="btn text-btn delete-weight" data-id="${entry.id}">Delete</button>
</td>
`;
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 = `
<td colspan="6" class="empty-state">No meal entries found. Use the form above to add your first entry.</td>
`;
tables.meal.appendChild(emptyRow);
return;
}
// Render each meal entry
meals.forEach(entry => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${Utils.formatDate(entry.date)}</td>
<td>${Utils.truncateText(entry.breakfast, 30)}</td>
<td>${Utils.truncateText(entry.lunch, 30)}</td>
<td>${Utils.truncateText(entry.dinner, 30)}</td>
<td>${Utils.truncateText(entry.otherMeals, 30)}</td>
<td>
<button class="btn text-btn edit-meal" data-id="${entry.id}">Edit</button>
<button class="btn text-btn delete-meal" data-id="${entry.id}">Delete</button>
</td>
`;
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
};
})();

168
js/utils.js Normal file
View File

@ -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
};
})();

56
nginx.conf Normal file
View File

@ -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;
}
}

18
package.json Normal file
View File

@ -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"
}
}

59
server.js Normal file
View File

@ -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`);
});

26
supervisord.conf Normal file
View File

@ -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