frontend implementation

This commit is contained in:
Roger Oriol
2025-10-09 20:19:20 +02:00
parent a1cede4157
commit efa7ff1d39
19 changed files with 3072 additions and 71 deletions

37
.dockerignore Normal file
View File

@@ -0,0 +1,37 @@
# Git files
.git
.gitignore
.jj
# Node modules (will be installed in container)
node_modules
backend/node_modules
# Database files (use volume mount for persistence)
data/
*.db
*.db-journal
# Documentation and planning
README.md
PLAN.md
CLAUDE.md
# Development files
.vscode
.idea
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS files
.DS_Store
Thumbs.db
# Temporary files
*.tmp
*.temp
*.swp
*.swo
*~

View File

@@ -0,0 +1,66 @@
# Multi-stage build for Gym Tracker application
# Stage 1: Dependencies
FROM node:20-alpine AS dependencies
WORKDIR /app
# Copy backend package files
COPY backend/package*.json ./backend/
# Install backend dependencies
RUN cd backend && npm ci --only=production
# Stage 2: Final image
FROM node:20-alpine
# Install nginx
RUN apk add --no-cache nginx
# Create app directory
WORKDIR /app
# Copy backend files and dependencies
COPY --from=dependencies /app/backend/node_modules ./backend/node_modules
COPY backend/package*.json ./backend/
COPY backend/*.js ./backend/
# Copy frontend files to nginx html directory
COPY frontend/ /usr/share/nginx/html/
# Copy nginx configuration
COPY nginx/nginx.conf /etc/nginx/nginx.conf
# Create data directory for SQLite database
RUN mkdir -p /app/data && chmod 755 /app/data
# Create nginx directories and set permissions
RUN mkdir -p /var/log/nginx /var/lib/nginx /run/nginx && \
chown -R nginx:nginx /var/log/nginx /var/lib/nginx /run/nginx
# Create startup script
RUN echo '#!/bin/sh' > /app/start.sh && \
echo 'echo "Starting Gym Tracker application..."' >> /app/start.sh && \
echo '' >> /app/start.sh && \
echo '# Start nginx in background' >> /app/start.sh && \
echo 'echo "Starting nginx..."' >> /app/start.sh && \
echo 'nginx' >> /app/start.sh && \
echo '' >> /app/start.sh && \
echo '# Start backend server' >> /app/start.sh && \
echo 'echo "Starting backend server on port 3000..."' >> /app/start.sh && \
echo 'cd /app/backend' >> /app/start.sh && \
echo 'exec node server.js' >> /app/start.sh && \
chmod +x /app/start.sh
# Expose ports
EXPOSE 80 3000
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1
# Run startup script
CMD ["/app/start.sh"]

View File

@@ -1,8 +1,8 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
import sqlite3 from 'sqlite3';
import path from 'path';
// Database file path
const DB_PATH = path.join(__dirname, '../data/gym-tracker.db');
const DB_PATH = path.join('../data/gym-tracker.db');
// Initialize database connection
const db = new sqlite3.Database(DB_PATH, (err) => {
@@ -15,7 +15,7 @@ const db = new sqlite3.Database(DB_PATH, (err) => {
});
// Create tables if they don't exist
function initializeDatabase() {
export function initializeDatabase() {
const createTableSQL = `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
@@ -34,7 +34,7 @@ function initializeDatabase() {
}
// Get all sessions
function getAllSessions(callback) {
export function getAllSessions(callback) {
const sql = 'SELECT * FROM sessions ORDER BY date DESC';
db.all(sql, [], (err, rows) => {
if (err) {
@@ -52,7 +52,7 @@ function getAllSessions(callback) {
}
// Get session by ID
function getSessionById(id, callback) {
export function getSessionById(id, callback) {
const sql = 'SELECT * FROM sessions WHERE id = ?';
db.get(sql, [id], (err, row) => {
if (err) {
@@ -71,7 +71,7 @@ function getSessionById(id, callback) {
}
// Create new session
function createSession(id, date, muscleGroups, callback) {
export function createSession(id, date, muscleGroups, callback) {
const sql = 'INSERT INTO sessions (id, date, muscle_groups) VALUES (?, ?, ?)';
const muscleGroupsJSON = JSON.stringify(muscleGroups);
@@ -85,7 +85,7 @@ function createSession(id, date, muscleGroups, callback) {
}
// Update existing session
function updateSession(id, date, muscleGroups, callback) {
export function updateSession(id, date, muscleGroups, callback) {
const sql = 'UPDATE sessions SET date = ?, muscle_groups = ? WHERE id = ?';
const muscleGroupsJSON = JSON.stringify(muscleGroups);
@@ -101,7 +101,7 @@ function updateSession(id, date, muscleGroups, callback) {
}
// Delete session
function deleteSession(id, callback) {
export function deleteSession(id, callback) {
const sql = 'DELETE FROM sessions WHERE id = ?';
db.run(sql, [id], function(err) {
@@ -116,7 +116,7 @@ function deleteSession(id, callback) {
}
// Close database connection
function closeDatabase() {
export function closeDatabase() {
db.close((err) => {
if (err) {
console.error('Error closing database:', err.message);
@@ -125,12 +125,3 @@ function closeDatabase() {
}
});
}
module.exports = {
getAllSessions,
getSessionById,
createSession,
updateSession,
deleteSession,
closeDatabase
};

1392
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,13 +4,16 @@
"description": "Gym tracker application back-end",
"license": "ISC",
"author": "Roger Oriol",
"type": "commonjs",
"type": "module",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"dependencies": {
"express": "^5.1.0"
"cors": "^2.8.5",
"express": "^5.1.0",
"sqlite3": "^5.1.7",
"uuid": "^13.0.0"
}
}

View File

@@ -1,7 +1,7 @@
const express = require('express');
const cors = require('cors');
const { v4: uuidv4 } = require('uuid');
const db = require('./database');
import express from 'express';
import cors from 'cors';
import { v4 as uuidv4 } from 'uuid';
import * as db from './database.js';
const app = express();
const PORT = process.env.PORT || 3000;

BIN
data/gym-tracker.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,145 @@
// API client for backend communication
const API_BASE_URL = 'http://localhost:3000/api';
/**
* Fetch all gym sessions from the backend
* @returns {Promise<Array>} Array of session objects
*/
async function fetchSessions() {
try {
const response = await fetch(`${API_BASE_URL}/sessions`);
if (!response.ok) {
throw new Error(`Failed to fetch sessions: ${response.statusText}`);
}
const sessions = await response.json();
return sessions;
} catch (error) {
console.error('Error fetching sessions:', error);
throw error;
}
}
/**
* Create a new gym session
* @param {Object} session - Session object with date and muscle_groups
* @param {string} session.date - Date in ISO format (YYYY-MM-DD)
* @param {Array<string>} session.muscle_groups - Array of muscle group names
* @returns {Promise<Object>} Created session object
*/
async function createSession(session) {
try {
const response = await fetch(`${API_BASE_URL}/sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(session),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to create session: ${response.statusText}`);
}
const createdSession = await response.json();
return createdSession;
} catch (error) {
console.error('Error creating session:', error);
throw error;
}
}
/**
* Update an existing gym session
* @param {string} id - Session ID
* @param {Object} session - Updated session object
* @param {string} session.date - Date in ISO format (YYYY-MM-DD)
* @param {Array<string>} session.muscle_groups - Array of muscle group names
* @returns {Promise<Object>} Updated session object
*/
async function updateSession(id, session) {
try {
const response = await fetch(`${API_BASE_URL}/sessions/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(session),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to update session: ${response.statusText}`);
}
const updatedSession = await response.json();
return updatedSession;
} catch (error) {
console.error('Error updating session:', error);
throw error;
}
}
/**
* Delete a gym session
* @param {string} id - Session ID
* @returns {Promise<void>}
*/
async function deleteSession(id) {
try {
const response = await fetch(`${API_BASE_URL}/sessions/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to delete session: ${response.statusText}`);
}
return;
} catch (error) {
console.error('Error deleting session:', error);
throw error;
}
}
/**
* Show loading indicator
*/
function showLoading() {
const loadingIndicator = document.getElementById('loadingIndicator');
if (loadingIndicator) {
loadingIndicator.classList.add('active');
}
}
/**
* Hide loading indicator
*/
function hideLoading() {
const loadingIndicator = document.getElementById('loadingIndicator');
if (loadingIndicator) {
loadingIndicator.classList.remove('active');
}
}
/**
* Show error message to user
* @param {string} message - Error message to display
*/
function showError(message) {
alert(`Error: ${message}`);
}
/**
* Show success message to user
* @param {string} message - Success message to display
*/
function showSuccess(message) {
// For now, we'll use console.log
// In a production app, you might use a toast notification
console.log(`Success: ${message}`);
}

View File

@@ -0,0 +1,247 @@
// Main Application Logic
// Application state
let sessions = [];
let currentEditingSessionId = null;
/**
* Initialize the application
*/
async function init() {
try {
showLoading();
// Load sessions from backend
await loadSessions();
// Set up event listeners
setupEventListeners();
// Initial render
updateUI();
hideLoading();
} catch (error) {
hideLoading();
showError('Failed to initialize application: ' + error.message);
}
}
/**
* Load all sessions from the backend
*/
async function loadSessions() {
try {
sessions = await fetchSessions();
} catch (error) {
console.error('Error loading sessions:', error);
throw error;
}
}
/**
* Set up all event listeners
*/
function setupEventListeners() {
// Add Workout button
const addWorkoutBtn = document.getElementById('addWorkoutBtn');
if (addWorkoutBtn) {
addWorkoutBtn.addEventListener('click', openAddSessionModal);
}
// Modal close button
const closeModalBtn = document.getElementById('closeModal');
if (closeModalBtn) {
closeModalBtn.addEventListener('click', closeModal);
}
// Cancel button
const cancelBtn = document.getElementById('cancelBtn');
if (cancelBtn) {
cancelBtn.addEventListener('click', closeModal);
}
// Session form submit
const sessionForm = document.getElementById('sessionForm');
if (sessionForm) {
sessionForm.addEventListener('submit', handleSessionFormSubmit);
}
// Close modal when clicking outside
const modal = document.getElementById('sessionModal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
});
}
}
/**
* Open modal for adding a new session
*/
function openAddSessionModal() {
currentEditingSessionId = null;
const modalTitle = document.getElementById('modalTitle');
if (modalTitle) {
modalTitle.textContent = 'Add Workout Session';
}
// Set default date to today
const dateInput = document.getElementById('sessionDate');
if (dateInput) {
const today = new Date().toISOString().split('T')[0];
dateInput.value = today;
}
// Clear all checkboxes
const checkboxes = document.querySelectorAll('input[name="muscleGroup"]');
checkboxes.forEach(cb => cb.checked = false);
showModal();
}
/**
* Open modal for editing an existing session
* @param {string} sessionId - ID of the session to edit
*/
function openEditSessionModal(sessionId) {
const session = sessions.find(s => s.id === sessionId);
if (!session) {
showError('Session not found');
return;
}
currentEditingSessionId = sessionId;
const modalTitle = document.getElementById('modalTitle');
if (modalTitle) {
modalTitle.textContent = 'Edit Workout Session';
}
// Set date
const dateInput = document.getElementById('sessionDate');
if (dateInput) {
dateInput.value = session.date;
}
// Set checkboxes
const checkboxes = document.querySelectorAll('input[name="muscleGroup"]');
checkboxes.forEach(cb => {
cb.checked = session.muscle_groups.includes(cb.value);
});
showModal();
}
/**
* Show the modal
*/
function showModal() {
const modal = document.getElementById('sessionModal');
if (modal) {
modal.classList.add('active');
}
}
/**
* Close the modal
*/
function closeModal() {
const modal = document.getElementById('sessionModal');
if (modal) {
modal.classList.remove('active');
}
currentEditingSessionId = null;
}
/**
* Handle session form submission
* @param {Event} e - Form submit event
*/
async function handleSessionFormSubmit(e) {
e.preventDefault();
const dateInput = document.getElementById('sessionDate');
const checkboxes = document.querySelectorAll('input[name="muscleGroup"]:checked');
const date = dateInput.value;
const muscle_groups = Array.from(checkboxes).map(cb => cb.value);
// Validate that at least one muscle group is selected
if (muscle_groups.length === 0) {
showError('Please select at least one muscle group');
return;
}
const sessionData = {
date,
muscle_groups
};
try {
showLoading();
if (currentEditingSessionId) {
// Update existing session
await updateSession(currentEditingSessionId, sessionData);
showSuccess('Session updated successfully');
} else {
// Create new session
await createSession(sessionData);
showSuccess('Session added successfully');
}
// Reload sessions and update UI
await loadSessions();
updateUI();
closeModal();
hideLoading();
} catch (error) {
hideLoading();
showError('Failed to save session: ' + error.message);
}
}
/**
* Delete a session
* @param {string} sessionId - ID of the session to delete
*/
async function handleDeleteSession(sessionId) {
if (!confirm('Are you sure you want to delete this session?')) {
return;
}
try {
showLoading();
await deleteSession(sessionId);
await loadSessions();
updateUI();
hideLoading();
showSuccess('Session deleted successfully');
} catch (error) {
hideLoading();
showError('Failed to delete session: ' + error.message);
}
}
/**
* Update the entire UI with current sessions data
*/
function updateUI() {
// Update muscle groups dashboard
updateMuscleGroupsDashboard(sessions);
// Update heatmap
updateHeatmap(sessions);
}
// Initialize the application when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}

132
frontend/heatmap.js Normal file
View File

@@ -0,0 +1,132 @@
// Heatmap Component using Cal-Heatmap library
let cal = null;
/**
* Initialize the Cal-Heatmap component
* @param {Array} sessions - Array of session objects
*/
function initHeatmap(sessions) {
const container = document.getElementById('heatmap');
if (!container) return;
// Transform sessions data for Cal-Heatmap
const heatmapData = transformSessionsForHeatmap(sessions);
// Initialize Cal-Heatmap
cal = new CalHeatmap();
const currentYear = new Date().getFullYear();
cal.paint({
itemSelector: '#heatmap',
domain: {
type: 'month',
label: {
position: 'bottom'
},
},
subDomain: {
type: 'day'
},
data: {
source: heatmapData,
type: 'json',
x: 'date',
y: 'value'
},
date: {
start: new Date(currentYear, 0, 1),
max: new Date(currentYear, 11, 31)
},
range: 12,
scale: {
color: {
type: 'threshold',
range: ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'],
domain: [1, 2, 3, 4]
}
},
itemName: ['workout', 'workouts'],
subDomainTextFormat: '%d',
tooltip: true
});
// Set up navigation controls
setupHeatmapControls();
}
/**
* Transform sessions array into format suitable for Cal-Heatmap
* @param {Array} sessions - Array of session objects
* @returns {Array} Transformed data for Cal-Heatmap
*/
function transformSessionsForHeatmap(sessions) {
const dateCounts = {};
sessions.forEach(session => {
const date = session.date;
// Count number of muscle groups trained (as measure of intensity)
const value = session.muscle_groups.length;
if (dateCounts[date]) {
dateCounts[date] += value;
} else {
dateCounts[date] = value;
}
});
// Convert to array format expected by Cal-Heatmap
return Object.entries(dateCounts).map(([date, value]) => ({
date,
value
}));
}
/**
* Update heatmap with new sessions data
* @param {Array} sessions - Array of session objects
*/
function updateHeatmap(sessions) {
if (!cal) {
initHeatmap(sessions);
return;
}
const heatmapData = transformSessionsForHeatmap(sessions);
// Update the heatmap data
cal.fill(heatmapData);
}
/**
* Set up navigation controls for the heatmap (previous/next)
*/
function setupHeatmapControls() {
const prevBtn = document.getElementById('heatmapPrev');
const nextBtn = document.getElementById('heatmapNext');
if (prevBtn && cal) {
prevBtn.addEventListener('click', () => {
cal.previous();
});
}
if (nextBtn && cal) {
nextBtn.addEventListener('click', () => {
cal.next();
});
}
}
/**
* Destroy and reinitialize the heatmap
* @param {Array} sessions - Array of session objects
*/
function reinitializeHeatmap(sessions) {
if (cal) {
cal.destroy();
cal = null;
}
initHeatmap(sessions);
}

View File

@@ -2,55 +2,110 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>Gym tracker</title>
<base href="/">
<meta name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0">
<meta name="description" content="">
<meta name="keywords" content="">
<meta name="author" content="">
<meta name="application-name" content="">
<meta name="theme-color" content="#33d">
<meta name="color-scheme" content="light dark">
<meta property="og:title" content="" />
<meta property="og:type" content="website" />
<meta property="og:url" content="" />
<meta property="og:image" content="" />
<link rel="canonical" href="" />
<link rel="manifest" href="manifest.json">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gym Tracker</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://unpkg.com/cal-heatmap/dist/cal-heatmap.css">
<link rel="preload"
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
as="style" onload="this.onload=null;this.rel='stylesheet'">
<link rel="preconnect" href="https://fonts.gstatic.com">
<style>
body {
background: #fefefe;
color: #222;
font-family: 'Roboto', sans-serif;
padding: 1rem;
line-height: 1.8;
}
</style>
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="styles.css">
</noscript>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js');
});
}
</script>
<script type="module" src="app.js"></script>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script src="https://unpkg.com/cal-heatmap@4.2.4/dist/cal-heatmap.min.js"></script>
</head>
<body>
<div class="container">
<!-- Header -->
<header class="header">
<h1>Gym Tracker</h1>
<div id="balanceIndicator" class="balance-indicator">
<span id="balanceEmoji" class="balance-emoji">😐</span>
<span id="balanceText" class="balance-text">Neutral Balance</span>
</div>
</header>
<!-- Add Workout Button -->
<div class="action-bar">
<button id="addWorkoutBtn" class="btn-primary">Add Today's Workout</button>
</div>
<!-- Heatmap Section -->
<section class="heatmap-section">
<h2>Training Calendar</h2>
<div class="heatmap-controls">
<button id="heatmapPrev" class="btn-secondary">← Previous</button>
<button id="heatmapNext" class="btn-secondary">Next →</button>
</div>
<div id="heatmap" class="heatmap-container"></div>
</section>
<!-- Muscle Groups Dashboard -->
<section class="muscle-groups-section">
<h2>Muscle Groups</h2>
<div id="muscleGroupsGrid" class="muscle-groups-grid">
<!-- Muscle group cards will be dynamically inserted here -->
</div>
</section>
</div>
<!-- Modal for Adding/Editing Session -->
<div id="sessionModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">Add Workout Session</h3>
<button id="closeModal" class="close-btn">&times;</button>
</div>
<form id="sessionForm">
<div class="form-group">
<label for="sessionDate">Date:</label>
<input type="date" id="sessionDate" name="date" required>
</div>
<div class="form-group">
<label>Muscle Groups Trained:</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" name="muscleGroup" value="Chest">
<span>Chest</span>
</label>
<label class="checkbox-label">
<input type="checkbox" name="muscleGroup" value="Legs">
<span>Legs</span>
</label>
<label class="checkbox-label">
<input type="checkbox" name="muscleGroup" value="Delts">
<span>Delts</span>
</label>
<label class="checkbox-label">
<input type="checkbox" name="muscleGroup" value="Lats">
<span>Lats</span>
</label>
<label class="checkbox-label">
<input type="checkbox" name="muscleGroup" value="Triceps">
<span>Triceps</span>
</label>
<label class="checkbox-label">
<input type="checkbox" name="muscleGroup" value="Biceps">
<span>Biceps</span>
</label>
</div>
</div>
<div class="form-actions">
<button type="button" id="cancelBtn" class="btn-secondary">Cancel</button>
<button type="submit" id="saveBtn" class="btn-primary">Save</button>
</div>
</form>
</div>
</div>
<!-- Loading Indicator -->
<div id="loadingIndicator" class="loading-indicator">
<div class="spinner"></div>
</div>
<!-- Application Scripts -->
<script src="api.js" defer></script>
<script src="muscleGroups.js" defer></script>
<script src="heatmap.js" defer></script>
<script src="app.js" defer></script>
</body>
</html>

166
frontend/muscleGroups.js Normal file
View File

@@ -0,0 +1,166 @@
// Muscle Groups Dashboard Component
// Configuration for the 6 muscle groups
const MUSCLE_GROUPS = ['Chest', 'Legs', 'Delts', 'Lats', 'Triceps', 'Biceps'];
/**
* Calculate statistics for each muscle group based on sessions
* @param {Array} sessions - Array of session objects
* @returns {Object} Statistics for each muscle group
*/
function calculateMuscleGroupStats(sessions) {
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const stats = {};
MUSCLE_GROUPS.forEach(muscle => {
// Filter sessions that include this muscle group
const muscleSessions = sessions.filter(session =>
session.muscle_groups.includes(muscle)
);
// Sort by date descending
const sortedSessions = muscleSessions
.map(s => ({ ...s, dateObj: new Date(s.date) }))
.sort((a, b) => b.dateObj - a.dateObj);
// Calculate days since last trained
let daysSince = null;
if (sortedSessions.length > 0) {
const lastDate = sortedSessions[0].dateObj;
const diffTime = now - lastDate;
daysSince = Math.floor(diffTime / (1000 * 60 * 60 * 24));
}
// Count sessions in last 7 days
const last7Days = sortedSessions.filter(s => s.dateObj >= sevenDaysAgo).length;
// Count sessions in last 30 days
const last30Days = sortedSessions.filter(s => s.dateObj >= thirtyDaysAgo).length;
// Determine status (good, warning, bad)
let status;
if (daysSince === null) {
status = 'bad'; // Never trained
} else if (daysSince <= 3) {
status = 'good';
} else if (daysSince <= 7) {
status = 'warning';
} else {
status = 'bad';
}
stats[muscle] = {
daysSince,
last7Days,
last30Days,
status
};
});
return stats;
}
/**
* Render muscle group cards in the dashboard
* @param {Object} stats - Statistics for each muscle group
*/
function renderMuscleGroups(stats) {
const container = document.getElementById('muscleGroupsGrid');
if (!container) return;
container.innerHTML = '';
MUSCLE_GROUPS.forEach(muscle => {
const muscleStats = stats[muscle];
const card = document.createElement('div');
card.className = `muscle-card status-${muscleStats.status}`;
const daysSinceText = muscleStats.daysSince === null
? 'Never trained'
: muscleStats.daysSince === 0
? 'Trained today'
: `${muscleStats.daysSince} day${muscleStats.daysSince === 1 ? '' : 's'} ago`;
card.innerHTML = `
<div class="muscle-card-header">
<h3 class="muscle-name">${muscle}</h3>
<div class="status-indicator ${muscleStats.status}"></div>
</div>
<div class="muscle-card-stats">
<div class="stat-row">
<span class="stat-label">Last trained:</span>
<span class="stat-value">${daysSinceText}</span>
</div>
<div class="stat-row">
<span class="stat-label">Last 7 days:</span>
<span class="stat-value">${muscleStats.last7Days}x</span>
</div>
<div class="stat-row">
<span class="stat-label">Last 30 days:</span>
<span class="stat-value">${muscleStats.last30Days}x</span>
</div>
</div>
`;
container.appendChild(card);
});
}
/**
* Calculate and update balance indicator
* @param {Object} stats - Statistics for each muscle group
*/
function updateBalanceIndicator(stats) {
const emojiElement = document.getElementById('balanceEmoji');
const textElement = document.getElementById('balanceText');
if (!emojiElement || !textElement) return;
// Check if all muscle groups meet certain criteria
const allTrainedLast7Days = MUSCLE_GROUPS.every(muscle =>
stats[muscle].last7Days >= 1
);
const allTrainedTwiceLast7Days = MUSCLE_GROUPS.every(muscle =>
stats[muscle].last7Days >= 2
);
let emoji, text, status;
if (allTrainedTwiceLast7Days) {
emoji = '=+';
text = 'Excellent Balance';
status = 'happy';
} else if (allTrainedLast7Days) {
emoji = '=';
text = 'Neutral Balance';
status = 'neutral';
} else {
emoji = '= ';
text = 'Poor Balance';
status = 'angry';
}
emojiElement.textContent = emoji;
textElement.textContent = text;
// Add status class for potential styling
const indicator = document.getElementById('balanceIndicator');
if (indicator) {
indicator.className = `balance-indicator balance-${status}`;
}
}
/**
* Update the entire muscle groups dashboard
* @param {Array} sessions - Array of session objects
*/
function updateMuscleGroupsDashboard(sessions) {
const stats = calculateMuscleGroupStats(sessions);
renderMuscleGroups(stats);
updateBalanceIndicator(stats);
}

View File

@@ -0,0 +1,411 @@
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding: 20px 0;
border-bottom: 2px solid #e0e0e0;
}
.header h1 {
font-size: 2rem;
font-weight: 700;
color: #2c3e50;
}
.balance-indicator {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.balance-emoji {
font-size: 2rem;
}
.balance-text {
font-size: 1rem;
font-weight: 500;
color: #555;
}
/* Action Bar */
.action-bar {
margin-bottom: 30px;
text-align: center;
}
/* Buttons */
.btn-primary {
background: #4CAF50;
color: white;
border: none;
padding: 12px 24px;
font-size: 1rem;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: background 0.3s ease;
}
.btn-primary:hover {
background: #45a049;
}
.btn-secondary {
background: #6c757d;
color: white;
border: none;
padding: 8px 16px;
font-size: 0.9rem;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: background 0.3s ease;
}
.btn-secondary:hover {
background: #5a6268;
}
/* Heatmap Section */
.heatmap-section {
background: white;
padding: 30px;
margin-bottom: 30px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.heatmap-section h2 {
font-size: 1.5rem;
margin-bottom: 20px;
color: #2c3e50;
}
.heatmap-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.heatmap-container {
overflow-x: auto;
}
/* Muscle Groups Section */
.muscle-groups-section {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.muscle-groups-section h2 {
font-size: 1.5rem;
margin-bottom: 20px;
color: #2c3e50;
}
.muscle-groups-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
/* Muscle Group Card */
.muscle-card {
background: #f9f9f9;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #ccc;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.muscle-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.muscle-card.status-good {
border-left-color: #4CAF50;
}
.muscle-card.status-warning {
border-left-color: #FFC107;
}
.muscle-card.status-bad {
border-left-color: #F44336;
}
.muscle-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.muscle-name {
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
}
.status-indicator.good {
background: #4CAF50;
}
.status-indicator.warning {
background: #FFC107;
}
.status-indicator.bad {
background: #F44336;
}
.muscle-card-stats {
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-row {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
}
.stat-label {
color: #666;
}
.stat-value {
font-weight: 600;
color: #333;
}
/* Modal */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
}
.modal.active {
display: flex;
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 0;
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.modal-header h3 {
font-size: 1.3rem;
color: #2c3e50;
}
.close-btn {
background: none;
border: none;
font-size: 1.8rem;
color: #999;
cursor: pointer;
line-height: 1;
}
.close-btn:hover {
color: #333;
}
/* Form */
#sessionForm {
padding: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #555;
}
.form-group input[type="date"] {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.checkbox-group {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s ease;
}
.checkbox-label:hover {
background: #e8e8e8;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.checkbox-label span {
font-size: 0.95rem;
color: #333;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 24px;
}
/* Loading Indicator */
.loading-indicator {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.3);
justify-content: center;
align-items: center;
}
.loading-indicator.active {
display: flex;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #4CAF50;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Responsive Design */
@media (max-width: 768px) {
.container {
padding: 10px;
}
.header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.header h1 {
font-size: 1.5rem;
}
.muscle-groups-grid {
grid-template-columns: 1fr;
}
.checkbox-group {
grid-template-columns: 1fr;
}
.heatmap-section,
.muscle-groups-section {
padding: 15px;
}
}
/* Cal-Heatmap Overrides */
.cal-heatmap-container {
font-family: inherit;
}
.cal-heatmap-container .graph-label {
fill: #666;
font-size: 12px;
}
.cal-heatmap-container rect.highlight {
stroke: #4CAF50;
stroke-width: 2;
}

158
kubernetes/README.md Normal file
View File

@@ -0,0 +1,158 @@
# Kubernetes Deployment
This directory contains Kubernetes manifests for deploying the Gym Tracker application.
## Prerequisites
- Kubernetes cluster (1.19+)
- kubectl configured to access your cluster
- Docker registry (optional, for remote deployments)
## Files
- **persistentvolumeclaim.yaml** - PVC for SQLite database storage
- **deployment.yaml** - Application deployment
- **service.yaml** - ClusterIP service for internal access
- **ingress.yaml** - Optional ingress for external access
## Deployment Steps
### 1. Build and Push Docker Image
Build the Docker image:
```bash
docker build -t gym-tracker:latest .
```
If deploying to a remote cluster, tag and push to your registry:
```bash
docker tag gym-tracker:latest your-registry/gym-tracker:latest
docker push your-registry/gym-tracker:latest
```
Update the image name in `deployment.yaml` accordingly.
### 2. Deploy to Kubernetes
Apply all manifests:
```bash
kubectl apply -f kubernetes/
```
Or apply individually in order:
```bash
kubectl apply -f kubernetes/persistentvolumeclaim.yaml
kubectl apply -f kubernetes/deployment.yaml
kubectl apply -f kubernetes/service.yaml
# Optional: kubectl apply -f kubernetes/ingress.yaml
```
### 3. Verify Deployment
Check the deployment status:
```bash
kubectl get pods -l app=gym-tracker
kubectl get svc gym-tracker
kubectl get pvc gym-tracker-data
```
View logs:
```bash
kubectl logs -l app=gym-tracker -f
```
### 4. Access the Application
#### Port Forward (for testing)
```bash
kubectl port-forward svc/gym-tracker 8080:80
```
Then access at http://localhost:8080
#### Using Ingress (for production)
1. Ensure an Ingress controller is installed in your cluster
2. Update the host in `ingress.yaml` with your domain
3. Apply the ingress: `kubectl apply -f kubernetes/ingress.yaml`
4. Access at http://your-domain.com
#### Using LoadBalancer
Change the service type in `service.yaml` from `ClusterIP` to `LoadBalancer`:
```yaml
spec:
type: LoadBalancer
```
Then get the external IP:
```bash
kubectl get svc gym-tracker
```
## Configuration
### Storage
The default PVC requests 1Gi of storage. Adjust in `persistentvolumeclaim.yaml`:
```yaml
resources:
requests:
storage: 5Gi # Increase as needed
```
### Replicas
The application uses SQLite, which is file-based. Keep replicas at 1 to avoid database locking issues:
```yaml
spec:
replicas: 1
```
For high availability, consider migrating to PostgreSQL or MySQL.
### Resources
Adjust resource limits in `deployment.yaml` based on your needs:
```yaml
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "1Gi"
cpu: "1000m"
```
## Backup
To backup the SQLite database:
```bash
kubectl exec -it <pod-name> -- sqlite3 /app/data/gym-tracker.db ".backup /app/data/backup.db"
kubectl cp <pod-name>:/app/data/backup.db ./backup.db
```
## Troubleshooting
### Pod not starting
```bash
kubectl describe pod -l app=gym-tracker
kubectl logs -l app=gym-tracker
```
### Database issues
Check volume mount:
```bash
kubectl exec -it <pod-name> -- ls -la /app/data
```
### Health check failures
Test health endpoint:
```bash
kubectl exec -it <pod-name> -- wget -O- http://localhost/health
```
## Cleanup
Remove all resources:
```bash
kubectl delete -f kubernetes/
```
**Warning:** This will delete the PVC and all data. Backup first if needed.

View File

@@ -0,0 +1,62 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gym-tracker
labels:
app: gym-tracker
spec:
replicas: 1 # Single replica since we're using SQLite with file-based storage
selector:
matchLabels:
app: gym-tracker
template:
metadata:
labels:
app: gym-tracker
spec:
containers:
- name: gym-tracker
image: gym-tracker:latest # Update with your registry/image name
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80
protocol: TCP
- name: api
containerPort: 3000
protocol: TCP
env:
- name: NODE_ENV
value: "production"
- name: PORT
value: "3000"
volumeMounts:
- name: data
mountPath: /app/data
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
volumes:
- name: data
persistentVolumeClaim:
claimName: gym-tracker-data

30
kubernetes/ingress.yaml Normal file
View File

@@ -0,0 +1,30 @@
# Optional: Ingress for external access
# Requires an Ingress controller (e.g., nginx-ingress, traefik) installed in your cluster
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gym-tracker
labels:
app: gym-tracker
annotations:
# Uncomment and adjust based on your ingress controller
# nginx.ingress.kubernetes.io/rewrite-target: /
# cert-manager.io/cluster-issuer: letsencrypt-prod # For HTTPS with cert-manager
spec:
ingressClassName: nginx # Adjust based on your ingress controller
rules:
- host: gym-tracker.example.com # Update with your domain
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: gym-tracker
port:
number: 80
# Uncomment for HTTPS
# tls:
# - hosts:
# - gym-tracker.example.com
# secretName: gym-tracker-tls

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gym-tracker-data
labels:
app: gym-tracker
spec:
accessModes:
- ReadWriteOnce # Single node access for SQLite
resources:
requests:
storage: 1Gi # Adjust size as needed
# storageClassName: standard # Uncomment and adjust based on your cluster's storage classes

15
kubernetes/service.yaml Normal file
View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: gym-tracker
labels:
app: gym-tracker
spec:
type: ClusterIP # Change to LoadBalancer or NodePort if external access is needed
ports:
- port: 80
targetPort: 80
protocol: TCP
name: http
selector:
app: gym-tracker

View File

@@ -0,0 +1,80 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
server {
listen 80;
server_name localhost;
# Root directory for static files
root /usr/share/nginx/html;
index index.html;
# Serve static frontend files
location / {
try_files $uri $uri/ /index.html;
# Cache control for static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Disable buffering for real-time responses
proxy_buffering off;
}
# Health check endpoint (proxy to backend)
location /health {
proxy_pass http://localhost:3000/health;
proxy_http_version 1.1;
proxy_set_header Host $host;
access_log off;
}
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}