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

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