frontend implementation
This commit is contained in:
37
.dockerignore
Normal file
37
.dockerignore
Normal 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
|
||||
*~
|
||||
66
Dockerfile
66
Dockerfile
@@ -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"]
|
||||
|
||||
@@ -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
1392
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
BIN
data/gym-tracker.db
Normal file
Binary file not shown.
145
frontend/api.js
145
frontend/api.js
@@ -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}`);
|
||||
}
|
||||
|
||||
247
frontend/app.js
247
frontend/app.js
@@ -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
132
frontend/heatmap.js
Normal 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);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
<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>
|
||||
<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">
|
||||
</noscript>
|
||||
<link rel="stylesheet" href="https://unpkg.com/cal-heatmap/dist/cal-heatmap.css">
|
||||
|
||||
<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">×</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
166
frontend/muscleGroups.js
Normal 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);
|
||||
}
|
||||
@@ -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
158
kubernetes/README.md
Normal 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.
|
||||
62
kubernetes/deployment.yaml
Normal file
62
kubernetes/deployment.yaml
Normal 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
30
kubernetes/ingress.yaml
Normal 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
|
||||
13
kubernetes/persistentvolumeclaim.yaml
Normal file
13
kubernetes/persistentvolumeclaim.yaml
Normal 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
15
kubernetes/service.yaml
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user