frontend implementation
This commit is contained in:
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">
|
||||
<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">×</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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user