const API_URL = '/api'; // --- Auth Check --- const token = localStorage.getItem('token'); if (!token && window.location.pathname !== '/login.html') { window.location.href = '/login.html'; } // --- Fetch Wrapper --- const originalFetch = window.fetch; window.fetch = async (...args) => { let [resource, config] = args; config = config || {}; config.headers = config.headers || {}; // Add Token const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } const response = await originalFetch(resource, config); if (response.status === 401) { localStorage.removeItem('token'); window.location.href = '/login.html'; } return response; }; // --- State --- const state = { challenges: [], loading: false, charts: {}, // Store chart instances healthCache: {}, healthPeriod: 'week', healthChart: null }; // --- DOM Elements --- const themeToggle = document.getElementById('theme-toggle'); const addChallengeBtn = document.getElementById('add-challenge-btn'); const modal = document.getElementById('add-challenge-modal'); const modalBackdrop = document.getElementById('modal-backdrop'); const closeModalBtn = document.getElementById('close-modal-btn'); const createForm = document.getElementById('create-challenge-form'); const challengesGrid = document.getElementById('challenges-grid'); const emptyState = document.getElementById('empty-state'); const totalRepsEl = document.getElementById('total-reps-today'); const activeStreakEl = document.getElementById('active-streak'); const completionRateEl = document.getElementById('completion-rate'); // New Elements for v2 const statsBtn = document.getElementById('stats-btn'); const exportBtn = document.getElementById('export-btn'); const statsModal = document.getElementById('stats-modal'); const closeStatsBtn = document.getElementById('close-stats-modal-btn'); const statsBackdrop = document.getElementById('stats-modal-backdrop'); // --- Initialization --- document.addEventListener('DOMContentLoaded', () => { initTheme(); // Logout Handler const logoutBtn = document.getElementById('logout-btn'); if (logoutBtn) { logoutBtn.addEventListener('click', () => { localStorage.removeItem('token'); window.location.href = '/login.html'; }); } initDateInputs(); initSortable(); initImageDropdown(); initFrequencyHandlers(); initDatePickerModal(); // Date picker modal for health dashboard // initGoogleSync(); // Removed fetchChallenges(); // fetchHealthSummary(); // Removed initHealthDashboard(); // New }); // --- Theme Handling --- function initTheme() { if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } themeToggle.addEventListener('click', () => { if (document.documentElement.classList.contains('dark')) { document.documentElement.classList.remove('dark'); localStorage.theme = 'light'; } else { document.documentElement.classList.add('dark'); localStorage.theme = 'dark'; } }); } function initDateInputs() { const today = new Date().toISOString().split('T')[0]; const inputs = document.querySelectorAll('input[type="date"]'); inputs.forEach(input => { if (!input.value) input.value = today; }); // Default end date to 30 days const endDateInput = document.querySelector('input[name="end_date"]'); if (endDateInput) { const future = new Date(); future.setDate(future.getDate() + 30); endDateInput.value = future.toISOString().split('T')[0]; } } // --- Drag & Drop (SortableJS) --- function initSortable() { new Sortable(challengesGrid, { animation: 150, handle: '.drag-handle', // We will add this class to the card header ghostClass: 'bg-indigo-50', onEnd: function (evt) { const itemEl = evt.item; // Get new order const newOrderIds = Array.from(challengesGrid.children).map(child => parseInt(child.dataset.id)); saveOrder(newOrderIds); }, }); } async function saveOrder(orderedIds) { try { await fetch(`${API_URL}/challenges/reorder`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(orderedIds) }); } catch (error) { console.error('Error saving order:', error); } } // --- Modal Handling --- function toggleModal(el, show) { const content = el.querySelector('div[class*="transform"]'); if (show) { el.classList.remove('invisible', 'opacity-0'); content.classList.remove('scale-95'); content.classList.add('scale-100'); } else { el.classList.add('invisible', 'opacity-0'); content.classList.add('scale-95'); content.classList.remove('scale-100'); } } // Rest Days Toggle const restDaysToggle = document.getElementById('rest-days-toggle'); const restDaysSelector = document.getElementById('rest-days-selector'); const unitSelect = document.querySelector('select[name="unit_type"]'); const dailyTargetLabel = document.getElementById('daily-target-label'); if (unitSelect && dailyTargetLabel) { unitSelect.addEventListener('change', (e) => { const unit = e.target.options[e.target.selectedIndex].text; dailyTargetLabel.innerText = `Daily Target (${unit})`; }); } // Frequency Handling function initFrequencyHandlers() { const periodSelect = document.getElementById('period-type-select'); const optionsContainer = document.getElementById('frequency-sub-options'); const dailyOptions = document.getElementById('daily-options'); const weeklyOptions = document.getElementById('weekly-options'); const monthlyOptions = document.getElementById('monthly-options'); if (!periodSelect) return; function updateVisibility() { const val = periodSelect.value; dailyOptions.classList.add('hidden'); weeklyOptions.classList.add('hidden'); monthlyOptions.classList.add('hidden'); // Always show options container if there's something to show? // Actually, we can just toggle the inner divs if (val === 'daily') dailyOptions.classList.remove('hidden'); if (val === 'weekly') weeklyOptions.classList.remove('hidden'); if (val === 'monthly') monthlyOptions.classList.remove('hidden'); } periodSelect.addEventListener('change', updateVisibility); // Init updateVisibility(); // Wire up old rest days logic to new rest days toggle for Daily if (restDaysToggle) { restDaysToggle.addEventListener('change', (e) => { const selector = document.getElementById('rest-days-selector'); if (e.target.checked) { selector.classList.remove('hidden'); selector.classList.add('grid'); } else { selector.classList.add('hidden'); selector.classList.remove('grid'); selector.querySelectorAll('input').forEach(cb => cb.checked = false); } }); } } addChallengeBtn.addEventListener('click', () => toggleModal(modal, true)); function resetModal() { toggleModal(modal, false); createForm.reset(); // Reset Frequency Logic const periodSelect = document.getElementById('period-type-select'); if (periodSelect) { periodSelect.value = 'daily'; periodSelect.dispatchEvent(new Event('change')); } if (restDaysToggle) { restDaysToggle.checked = false; // Trigger generic reset if needed const selector = document.getElementById('rest-days-selector'); if (selector) { selector.classList.add('hidden'); selector.classList.remove('grid'); selector.querySelectorAll('input').forEach(cb => cb.checked = false); } } // Clear Weekly document.querySelectorAll('input[name="weekly_days"]').forEach(cb => cb.checked = false); // Clear Monthly const monthlyInput = document.querySelector('input[name="frequency_count"]'); if (monthlyInput) monthlyInput.value = ''; // Reset icon to default const defaultIcon = document.querySelector('input[name="icon"][value="fa-dumbbell"]'); if (defaultIcon) defaultIcon.checked = true; // Reset Image Dropdown const previewContainer = document.getElementById('image-preview-container'); const placeholder = document.getElementById('no-image-placeholder'); const selectedText = document.getElementById('dropdown-selected-text'); const hiddenInput = document.getElementById('selected-image-url'); const imgPreview = document.getElementById('image-preview'); if (previewContainer) previewContainer.classList.add('hidden'); if (placeholder) placeholder.classList.remove('hidden'); if (selectedText) selectedText.textContent = 'Select animation...'; if (hiddenInput) hiddenInput.value = ''; if (imgPreview) imgPreview.src = ''; initDateInputs(); } closeModalBtn.addEventListener('click', resetModal); modalBackdrop.addEventListener('click', resetModal); // Stats Modal // Stats Modal if (statsBtn) { statsBtn.addEventListener('click', () => { toggleModal(statsModal, true); renderCharts(); }); } if (closeStatsBtn) closeStatsBtn.addEventListener('click', () => toggleModal(statsModal, false)); if (statsBackdrop) statsBackdrop.addEventListener('click', () => toggleModal(statsModal, false)); // --- API Interactions --- async function fetchChallenges() { try { const response = await fetch(`${API_URL}/challenges`); if (!response.ok) throw new Error('Failed to fetch challenges'); state.challenges = await response.json(); renderDashboard(); } catch (error) { console.error('Error:', error); } } createForm.addEventListener('submit', async (e) => { e.preventDefault(); const formData = new FormData(createForm); const data = Object.fromEntries(formData.entries()); data.daily_target = parseInt(data.daily_target); if (!data.end_date) data.end_date = null; // Dynamic Data Packing based on Frequency if (data.period_type === 'daily') { const restDays = []; const selector = document.getElementById('rest-days-selector'); if (selector) { selector.querySelectorAll('input:checked').forEach(cb => restDays.push(cb.value)); } data.rest_days = restDays.length > 0 ? restDays.join(',') : null; data.selected_days = null; data.frequency_count = null; } else if (data.period_type === 'weekly') { const weeklyDays = []; document.querySelectorAll('input[name="weekly_days"]:checked').forEach(cb => weeklyDays.push(cb.value)); data.selected_days = weeklyDays.length > 0 ? weeklyDays.join(',') : null; data.rest_days = null; data.frequency_count = null; } else if (data.period_type === 'monthly') { data.frequency_count = parseInt(data.frequency_count) || null; data.rest_days = null; data.selected_days = null; } // unit_type is already in formData (select name="unit_type") try { const response = await fetch(`${API_URL}/challenges`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) throw new Error('Failed to create challenge'); await fetchChallenges(); resetModal(); // Use unified reset } catch (error) { console.error('Error creating challenge:', error); } }); async function updateTracking(challengeId, dateStr, reps) { const challenge = state.challenges.find(c => c.id === challengeId); if (!challenge) return; const completed = reps >= challenge.daily_target; try { const response = await fetch(`${API_URL}/tracking`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challenge_id: challengeId, date: dateStr, reps: reps, completed: completed }) }); if (!response.ok) throw new Error('Failed to update tracking'); await fetchChallenges(); } catch (error) { console.error('Error updating tracking:', error); } } async function deleteChallenge(id) { if (!confirm('Are you sure you want to delete this challenge?')) return; try { const response = await fetch(`${API_URL}/challenges/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Failed to delete'); await fetchChallenges(); } catch (error) { console.error('Error:', error); } } // --- Data Export --- exportBtn.addEventListener('click', () => { const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(state.challenges, null, 2)); const downloadAnchorNode = document.createElement('a'); downloadAnchorNode.setAttribute("href", dataStr); downloadAnchorNode.setAttribute("download", "habitforge_data.json"); document.body.appendChild(downloadAnchorNode); // required for firefox downloadAnchorNode.click(); downloadAnchorNode.remove(); }); // --- Gamification (Badges) --- function getBadges(challenge) { const badges = []; const today = new Date().toISOString().split('T')[0]; // Calculate total reps let totalReps = 0; let daysTracked = 0; let daysCompleted = 0; let currentStreak = 0; // Sort trackings by date descending const sortedTrackings = [...challenge.trackings].sort((a, b) => new Date(b.date) - new Date(a.date)); // Calculate Stats sortedTrackings.forEach(t => { totalReps += t.reps; if (t.reps > 0) daysTracked++; if (t.completed) daysCompleted++; }); // Calculate Streak (consecutive days with > 0 reps ending today or yesterday) // Simplified logic: Check yesterday, day before... // Ideally we iterate dates back from today. let checkDate = new Date(); // Allow missing today if checked yesterday // Let's rely on sortedTrackings for simple check // This is a naive streak implementation for demo for (let i = 0; i < sortedTrackings.length; i++) { const t = sortedTrackings[i]; // Logic is complex without a full date map. // Let's simplifiy: if recent 7 days have 7 completions -> Perfect Week } // 100 Reps Club if (totalReps >= 100) { badges.push({ icon: 'fa-dumbbell', color: 'text-blue-500', title: '100 Reps Club' }); } // High Volume (1000 Reps) if (totalReps >= 1000) { badges.push({ icon: 'fa-trophy', color: 'text-yellow-500', title: '1k Legend' }); } // Consistency (Example: 5+ completions) if (daysCompleted >= 5) { badges.push({ icon: 'fa-fire', color: 'text-orange-500', title: 'On Fire (5+ Days)' }); } return badges; } // --- Rendering --- function renderDashboard() { updateStats(); renderBadges(); if (state.challenges.length === 0) { challengesGrid.innerHTML = ''; emptyState.classList.remove('hidden'); return; } emptyState.classList.add('hidden'); challengesGrid.innerHTML = ''; state.challenges.forEach((challenge, index) => { const card = createChallengeCard(challenge); challengesGrid.appendChild(card); anime({ targets: card, translateY: [20, 0], opacity: [0, 1], delay: index * 50, easing: 'easeOutQuad', duration: 300 }); }); } function updateStats() { const today = new Date().toISOString().split('T')[0]; let totalReps = 0; let completedCount = 0; let trackedCount = 0; state.challenges.forEach(c => { const t = c.trackings.find(t => t.date === today); if (t) { totalReps += t.reps; if (t.completed) completedCount++; if (t.reps > 0) trackedCount++; } }); // Update Header Numbers totalRepsEl.innerText = totalReps; activeStreakEl.innerText = trackedCount; // Simply Tracked Today for now const rate = state.challenges.length > 0 ? Math.round((completedCount / state.challenges.length) * 100) : 0; completionRateEl.innerText = rate + '%'; } function createChallengeCard(challenge) { const div = document.createElement('div'); div.className = 'bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden'; div.dataset.id = challenge.id; // Corrected dataset assignment // Get Badges const badges = getBadges(challenge); const badgesHtml = badges.map(b => ``).join(''); const today = new Date().toISOString().split('T')[0]; const todayTracking = challenge.trackings.find(t => t.date === today) || { reps: 0 }; const progressPercent = Math.min(100, (todayTracking.reps / challenge.daily_target) * 100); // Icon handling const iconClass = challenge.icon || 'fa-dumbbell'; // Image handling let imageHtml = ''; // Layout: Horizontal Card // Left: Image (or Icon placeholder if no image? No, prompt implies image available. If no image, existing layout preferred? User said "put image on left". I will assume this layout for image ones.) const hasImage = !!challenge.image_url; div.innerHTML = `
${hasImage ? ` ${challenge.name} ` : `
`}
${challenge.period_type} ${hasImage ? ` ` : ''}

${challenge.name}

${challenge.description ? `

${challenge.description}

` : ''}
Target: ${challenge.daily_target} ${challenge.unit_type || 'reps'}
${badgesHtml}
Today
${todayTracking.reps} / ${challenge.daily_target}
History ${generateMiniHeatmap(challenge)}
`; div.querySelector('.delete-btn').addEventListener('click', () => deleteChallenge(challenge.id)); return div; } window.adjustReps = (id, date, delta, current) => { const newReps = Math.max(0, current + delta); updateTracking(id, date, newReps); }; window.manualReps = (id, date, value) => { const newReps = Math.max(0, parseInt(value) || 0); updateTracking(id, date, newReps); }; function generateMiniHeatmap(challenge) { let html = ''; const today = new Date(); // Show last 7 days + today (total 8? or just 7). Let's do 7. for (let i = 6; i >= 0; i--) { const d = new Date(today); d.setDate(d.getDate() - i); const dateStr = d.toISOString().split('T')[0]; const tracking = challenge.trackings.find(t => t.date === dateStr); let colorClass = 'bg-gray-200 dark:bg-gray-700'; if (tracking) { if (tracking.completed) colorClass = 'bg-green-500'; else if (tracking.reps > 0) colorClass = 'bg-orange-400'; else if (new Date(dateStr) < new Date(today.toISOString().split('T')[0])) colorClass = 'bg-red-400/50'; } const dayName = d.toLocaleDateString('en-US', { weekday: 'narrow' }); // Tooltip with date and status const title = `${dateStr}: ${tracking ? tracking.reps : 0} `; html += `
`; } return html; } // --- Charts Logic --- function renderCharts() { const ctxReps = document.getElementById('chart-reps').getContext('2d'); const ctxComp = document.getElementById('chart-completion').getContext('2d'); // Prepare Data: Last 7 days const labels = []; const today = new Date(); for (let i = 6; i >= 0; i--) { const d = new Date(today); d.setDate(d.getDate() - i); labels.push(d.toLocaleDateString('en-US', { weekday: 'short' })); } // Aggregates const repsData = new Array(7).fill(0); const completionData = new Array(7).fill(0); // Naively iterate again for simplicity for (let i = 6; i >= 0; i--) { const d = new Date(today); d.setDate(d.getDate() - i); const dateStr = d.toISOString().split('T')[0]; const idx = 6 - i; state.challenges.forEach(c => { const t = c.trackings.find(tr => tr.date === dateStr); if (t) { repsData[idx] += t.reps; if (t.completed) completionData[idx] += 1; } }); // Convert count to percentage for completion if (state.challenges.length > 0) { completionData[idx] = Math.round((completionData[idx] / state.challenges.length) * 100); } } // Destroy existing if (state.charts.reps) state.charts.reps.destroy(); if (state.charts.comp) state.charts.comp.destroy(); const isDark = document.documentElement.classList.contains('dark'); const textColor = isDark ? '#9ca3af' : '#4b5563'; state.charts.reps = new Chart(ctxReps, { type: 'bar', data: { labels: labels, datasets: [{ label: 'Total Reps', data: repsData, backgroundColor: '#0ea5e9', borderRadius: 4 }] }, options: { responsive: true, scales: { y: { beginAtZero: true, grid: { color: isDark ? '#374151' : '#e5e7eb' }, ticks: { color: textColor } }, x: { grid: { display: false }, ticks: { color: textColor } } }, plugins: { legend: { display: false } } } }); state.charts.comp = new Chart(ctxComp, { type: 'line', data: { labels: labels, datasets: [{ label: 'Completion Rate %', data: completionData, borderColor: '#10b981', tension: 0.4, fill: true, backgroundColor: '#10b98120' }] }, options: { responsive: true, scales: { y: { beginAtZero: true, max: 100, grid: { color: isDark ? '#374151' : '#e5e7eb' }, ticks: { color: textColor } }, x: { grid: { display: false }, ticks: { color: textColor } } }, plugins: { legend: { display: false } } } }); renderHeatmap(); } function renderBadges() { const container = document.getElementById('badge-list'); if (!container) return; container.innerHTML = ''; // Calculate Global Stats let totalReps = 0; let maxDailyReps = 0; let daysCompletedChain = 0; // Simple streak logic let hasChallenge = state.challenges.length > 0; // We need to aggregate trackings by date to find max daily reps const dailyRepsMap = {}; const today = new Date().toISOString().split('T')[0]; state.challenges.forEach(c => { c.trackings.forEach(t => { totalReps += t.reps; if (!dailyRepsMap[t.date]) dailyRepsMap[t.date] = 0; dailyRepsMap[t.date] += t.reps; }); }); Object.values(dailyRepsMap).forEach(reps => { if (reps > maxDailyReps) maxDailyReps = reps; }); // Check Streak (Global: consecutive days with at least one completion?) // Prompt says "Active Streak de 7 jours". // We'll calculate it from "Active Streak" logic we already have or re-calculate. // Existing activeStreakEl suggests we might have it, but that logic was "Tracked Today". // Let's implement a simple "Last 7 days active" check for the badge. // Badge Definitions const badges = [ { id: 'beginner', title: 'Débutant', desc: 'Créer le premier challenge', icon: 'fa-rocket', unlocked: hasChallenge }, { id: 'onfire', title: 'En feu', desc: 'Atteindre une streak de 7 jours', icon: 'fa-fire', unlocked: parseInt(document.getElementById('active-streak').innerText) >= 7 // Reusing existing metric for simplicity or calculate proper streak }, { id: 'spartan', title: 'Spartiate', desc: '300 reps en une seule journée', icon: 'fa-shield-halved', unlocked: maxDailyReps >= 300 } ]; badges.forEach(b => { const div = document.createElement('div'); div.className = `flex flex-col items-center justify-center p-3 rounded-lg border w-24 flex-shrink-0 transition-all ${b.unlocked ? 'bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800' : 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 opacity-50 grayscale'}`; div.title = b.desc; div.innerHTML = `
${b.title} `; container.appendChild(div); }); } function renderHeatmap() { const container = document.getElementById('consistency-heatmap'); if (!container) return; container.innerHTML = ''; const grid = document.createElement('div'); grid.className = 'flex flex-wrap gap-1 max-w-full justify-start'; const today = new Date(); // Start 365 days ago const startDate = new Date(today); startDate.setDate(today.getDate() - 364); for (let i = 0; i < 365; i++) { const d = new Date(startDate); d.setDate(startDate.getDate() + i); const dateStr = d.toISOString().split('T')[0]; let activeChallengesCount = 0; let completedCount = 0; let anyActivity = false; state.challenges.forEach(c => { if (c.start_date <= dateStr && (!c.end_date || c.end_date >= dateStr)) { activeChallengesCount++; const t = c.trackings.find(tr => tr.date === dateStr); if (t) { if (t.reps > 0) anyActivity = true; if (t.completed) completedCount++; } } }); let colorClass = 'bg-gray-100 dark:bg-gray-700/50'; let title = `${dateStr}: No activity`; if (activeChallengesCount > 0) { if (completedCount === activeChallengesCount && activeChallengesCount > 0) { colorClass = 'bg-brand-500'; title = `${dateStr}: Perfect Day! (${completedCount}/${activeChallengesCount})`; } else if (anyActivity) { colorClass = 'bg-brand-300 dark:bg-brand-800'; title = `${dateStr}: Some activity`; } else { title = `${dateStr}: Missed`; } } const square = document.createElement('div'); square.className = `w-2.5 h-2.5 rounded-sm ${colorClass} transition-colors hover:ring-1 hover:ring-gray-400`; square.title = title; grid.appendChild(square); } container.appendChild(grid); } // --- Google Fit & Health Dashboard --- function initGoogleSync() { const btn = document.getElementById('sync-google-btn'); if (btn) { btn.addEventListener('click', async () => { try { const res = await fetch(`${API_URL}/google/login`); const data = await res.json(); if (data.url) { window.location.href = data.url; } } catch (err) { console.error("Error initiating login:", err); } }); } // Check for token in URL const params = new URLSearchParams(window.location.search); const token = params.get('google_token'); const error = params.get('error'); if (token) { // Clear URL window.history.replaceState({}, document.title, window.location.pathname); syncHealthData(token); } else if (error) { window.history.replaceState({}, document.title, window.location.pathname); showToast("Google Sync Error: " + error, 'error'); } } async function syncHealthData(token) { const btn = document.getElementById('sync-google-btn'); if (btn) btn.innerHTML = ' Syncing...'; try { const response = await fetch(`${API_URL}/health/sync`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ access_token: token }) }); if (!response.ok) throw new Error('Sync failed'); const result = await response.json(); console.log("Sync result:", result); // Refresh data await fetchHealthSummary(); showToast(`Synced ${result.days_processed} days from Google Fit!`, 'success'); } catch (error) { console.error('Error syncing:', error); showToast("Failed to sync data.", 'error'); } finally { if (btn) btn.innerHTML = ' Sync Data'; } } async function fetchHealthSummary() { try { const response = await fetch(`${API_URL}/health/summary?days=30`); if (!response.ok) return; // Maybe 404/401 const data = await response.json(); renderHealthDashboard(data); } catch (error) { console.error("Error fetching health summary:", error); } } function renderHealthDashboard(data) { const section = document.getElementById('health-overview'); if (!data || data.length === 0) { // section.classList.add('hidden'); // Keep visible so user sees Sync button? // Or show empty state document.getElementById('health-steps').innerText = '--'; document.getElementById('health-sleep').innerText = '--'; document.getElementById('health-pai').innerText = '--'; return; } section.classList.remove('hidden'); // 1. Update Cards (Latest Data) // Sort logic in backend was "asc". So last item is latest. const latest = data[data.length - 1]; const todayStr = new Date().toISOString().split('T')[0]; // Check if latest is today? If not, show latest available with date? // For now, just show latest sync point. if (latest) { document.getElementById('health-steps').innerText = latest.step_count.toLocaleString(); const h = Math.floor(latest.sleep_duration_minutes / 60); const m = latest.sleep_duration_minutes % 60; document.getElementById('health-sleep').innerText = `${h}h ${m}m`; const dh = Math.floor(latest.deep_sleep_minutes / 60); const dm = latest.deep_sleep_minutes % 60; document.getElementById('health-deep_sleep').innerText = `${dh}h ${dm}m Deep`; // Typo fix in HTML id needed? HTML has health-deep-sleep document.getElementById('health-pai').innerText = latest.pai_score.toFixed(1); // Update bar width (Goal: 10k steps?) const stepGoal = 10000; const pct = Math.min(100, (latest.step_count / stepGoal) * 100); document.getElementById('steps-bar').style.width = `${pct}%`; } // 2. Render Charts // Prepare Data const labels = data.map(d => new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); const stepsData = data.map(d => d.step_count); const calsData = data.map(d => d.calories_burned); const sleepData = data.map(d => Number((d.sleep_duration_minutes / 60).toFixed(1))); const paiData = data.map(d => d.pai_score); // Chart 1: Steps & Calories const ctx1 = document.getElementById('chart-steps-cals').getContext('2d'); if (state.charts.health1) state.charts.health1.destroy(); const isDark = document.documentElement.classList.contains('dark'); const textColor = isDark ? '#9ca3af' : '#4b5563'; state.charts.health1 = new Chart(ctx1, { type: 'bar', data: { labels: labels, datasets: [ { label: 'Steps', data: stepsData, backgroundColor: '#3b82f6', yAxisID: 'y' }, { label: 'Calories', data: calsData, type: 'line', borderColor: '#ef4444', borderWidth: 2, pointRadius: 0, tension: 0.4, yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { ticks: { color: textColor }, grid: { display: false } }, y: { type: 'linear', position: 'left', ticks: { color: textColor }, grid: { color: isDark ? '#374151' : '#e5e7eb' } }, y1: { type: 'linear', position: 'right', grid: { display: false }, ticks: { display: false } } // Hide calories ticks to reduce clutter } } }); // Chart 2: Sleep & PAI const ctx2 = document.getElementById('chart-health-metrics').getContext('2d'); if (state.charts.health2) state.charts.health2.destroy(); state.charts.health2 = new Chart(ctx2, { type: 'line', data: { labels: labels, datasets: [ { label: 'Sleep (hrs)', data: sleepData, borderColor: '#8b5cf6', backgroundColor: '#8b5cf620', fill: true, tension: 0.4, yAxisID: 'y' }, { label: 'PAI', data: paiData, borderColor: '#f97316', borderDash: [5, 5], tension: 0.4, yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { ticks: { color: textColor }, grid: { display: false } }, y: { title: { display: true, text: 'Hours' }, ticks: { color: textColor }, grid: { color: isDark ? '#374151' : '#e5e7eb' } }, y1: { type: 'linear', position: 'right', grid: { display: false }, title: { display: true, text: 'PAI' } } } } }); } // --- Custom Image Dropdown --- async function initImageDropdown() { const dropdown = document.getElementById('custom-image-dropdown'); if (!dropdown) return; const button = document.getElementById('dropdown-button'); const menu = document.getElementById('dropdown-menu'); const optionsContainer = document.getElementById('dropdown-options'); const hiddenInput = document.getElementById('selected-image-url'); const previewContainer = document.getElementById('image-preview-container'); const previewImage = document.getElementById('image-preview'); const selectedText = document.getElementById('dropdown-selected-text'); // Fetch images try { const response = await fetch('/api/static/images'); if (!response.ok) return; const images = await response.json(); if (images.length === 0) { optionsContainer.innerHTML = '
No images found
'; } else { optionsContainer.innerHTML = ''; images.forEach(img => { const url = `/static/${img}`; const div = document.createElement('div'); div.className = 'flex items-center gap-3 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg cursor-pointer transition-colors'; div.onclick = () => selectImage(url, img); div.innerHTML = `
${img} `; optionsContainer.appendChild(div); }); } } catch (e) { console.error("Failed to load images", e); } // Toggle button.addEventListener('click', (e) => { e.stopPropagation(); menu.classList.toggle('hidden'); const arrow = button.querySelector('.fa-chevron-down'); if (arrow) arrow.classList.toggle('rotate-180'); }); // Close on click outside document.addEventListener('click', (e) => { if (!dropdown.contains(e.target)) { menu.classList.add('hidden'); const arrow = button.querySelector('.fa-chevron-down'); if (arrow) arrow.classList.remove('rotate-180'); } }); function selectImage(url, name) { hiddenInput.value = url; selectedText.textContent = name; previewImage.src = url; previewContainer.classList.remove('hidden'); const placeholder = document.getElementById('no-image-placeholder'); if (placeholder) placeholder.classList.add('hidden'); menu.classList.add('hidden'); const arrow = button.querySelector('.fa-chevron-down'); if (arrow) arrow.classList.remove('rotate-180'); } } // --- Health Dashboard Logic --- // --- Health Dashboard Logic --- function initHealthDashboard() { // Initialize state properties if (!state.healthCache) state.healthCache = {}; if (!state.charts.health) state.charts.health = {}; // Store multiple health charts if (!state.healthPeriod) state.healthPeriod = 'week'; if (!state.currentDate) state.currentDate = new Date(); // Default to today const toggleBtn = document.getElementById('health-toggle-btn'); const dashboardView = document.getElementById('dashboard-view'); const healthView = document.getElementById('health-dashboard-view'); const timeframeBtns = document.querySelectorAll('.timeframe-btn'); const zeppConnectBtn = document.getElementById('zepp-connect-btn'); const zeppSyncBtn = document.getElementById('zepp-sync-btn'); const tabBtns = document.querySelectorAll('.health-tab-btn'); // specific date nav const datePrevBtn = document.getElementById('date-prev-btn'); const dateNextBtn = document.getElementById('date-next-btn'); // Toggle View if (toggleBtn) { toggleBtn.addEventListener('click', () => { const isHealth = healthView.classList.contains('hidden'); if (isHealth) { dashboardView.classList.add('hidden'); healthView.classList.remove('hidden'); toggleBtn.classList.add('bg-brand-50', 'text-brand-600'); toggleBtn.classList.remove('bg-white', 'text-gray-600'); // Reset date on open if needed, or keep last state? keeping last state is better. updateDateDisplay(); loadHealthData(state.healthPeriod); // Initialize Tabs if first open (default to overview) if (!document.querySelector('.health-tab-btn.active-tab')) { tabBtns[0].click(); } } else { healthView.classList.add('hidden'); dashboardView.classList.remove('hidden'); toggleBtn.classList.remove('bg-brand-50', 'text-brand-600'); toggleBtn.classList.add('bg-white', 'text-gray-600'); } }); } // Date Navigation Listeners if (datePrevBtn) datePrevBtn.addEventListener('click', () => changeDate(-1)); if (dateNextBtn) dateNextBtn.addEventListener('click', () => changeDate(1)); // Timeframe Selectors timeframeBtns.forEach(btn => { btn.addEventListener('click', () => { timeframeBtns.forEach(b => { b.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400'); b.classList.add('text-gray-500', 'dark:text-gray-400'); }); btn.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400'); btn.classList.remove('text-gray-500', 'dark:text-gray-400'); state.healthPeriod = btn.dataset.period; // Switch period-specific views within each tab switchPeriodViews(state.healthPeriod); updateDateDisplay(); loadHealthData(state.healthPeriod); }); }); // Tab Switching tabBtns.forEach(btn => { btn.addEventListener('click', () => { // Update Buttons tabBtns.forEach(b => { b.classList.remove('active-tab', 'text-brand-600', 'border-brand-600'); b.classList.add('text-gray-500', 'dark:text-gray-400', 'border-transparent'); }); btn.classList.add('active-tab', 'text-brand-600', 'border-brand-600'); btn.classList.remove('text-gray-500', 'dark:text-gray-400', 'border-transparent'); // Show Content document.querySelectorAll('.health-tab-content').forEach(c => c.classList.add('hidden')); const tabId = btn.dataset.tab; document.getElementById(`tab-${tabId}`).classList.remove('hidden'); // Load workouts data when switching to workouts tab if (tabId === 'workouts' && (!workoutState.workouts || workoutState.workouts.length === 0)) { loadWorkoutsData(); } // Re-render charts for this tab if data exists (to fix canvas size issues on hidden tabs) const dateStr = state.currentDate ? state.currentDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0]; const key = `${state.healthPeriod}_${dateStr}`; if (state.healthCache[key]) { renderHealthDashboard(state.healthCache[key], state.healthPeriod); } }); }); // Zepp Connect Button - Shows credentials modal if (zeppConnectBtn) { zeppConnectBtn.addEventListener('click', async () => { showZeppConnectModal(); }); } // Zepp Sync Button - Syncs data from connected account if (zeppSyncBtn) { zeppSyncBtn.addEventListener('click', async () => { await performZeppSync(); }); } } // --- Zepp Sync Functions --- function showZeppConnectModal() { // Create modal HTML const modalHtml = `

Connect Zepp Account

Enter your Zepp/Amazfit account credentials to sync data from your smartwatch.

`; // Add modal to DOM document.body.insertAdjacentHTML('beforeend', modalHtml); const modal = document.getElementById('zepp-connect-modal'); const closeBtn = document.getElementById('close-zepp-modal'); const cancelBtn = document.getElementById('cancel-zepp-connect'); const backdrop = document.getElementById('zepp-modal-backdrop'); const form = document.getElementById('zepp-connect-form'); const closeModal = () => modal.remove(); closeBtn.addEventListener('click', closeModal); cancelBtn.addEventListener('click', closeModal); backdrop.addEventListener('click', closeModal); form.addEventListener('submit', async (e) => { e.preventDefault(); const formData = new FormData(form); const email = formData.get('email'); const password = formData.get('password'); const submitBtn = form.querySelector('button[type="submit"]'); const originalText = submitBtn.innerHTML; submitBtn.innerHTML = ' Connecting...'; submitBtn.disabled = true; try { const res = await fetch(`${API_URL}/zepp/connect`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }); const data = await res.json(); if (res.ok && data.status === 'success') { showToast(`Connected to Zepp account: ${data.user_id}`, 'success'); closeModal(); // Update connect button to show connected state const connectBtn = document.getElementById('zepp-connect-btn'); if (connectBtn) { connectBtn.innerHTML = ''; connectBtn.classList.add('border-green-500'); } } else { const errorMsg = data.detail || 'Connection failed'; showToast(errorMsg, 'error'); } } catch (err) { console.error("Zepp connect failed", err); showToast("Failed to connect to Zepp", 'error'); } finally { submitBtn.innerHTML = originalText; submitBtn.disabled = false; } }); } async function performZeppSync() { const syncBtn = document.getElementById('zepp-sync-btn'); let originalText = ""; if (syncBtn) { originalText = syncBtn.innerHTML; syncBtn.innerHTML = ''; syncBtn.disabled = true; } try { const res = await fetch(`${API_URL}/zepp/sync`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ days: 30 }) }); const data = await res.json(); if (res.ok && data.status === 'success') { showToast(`Synced ${data.days_synced} days and ${data.workouts_synced} workouts from Zepp!`, 'success'); state.healthCache = {}; loadHealthData(state.healthPeriod); } else { let errorMsg = "Sync failed."; if (data.detail) { errorMsg = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail); } // If not connected yet, prompt to connect if (res.status === 400 && errorMsg.includes('not connected')) { showToast("Please connect your Zepp account first", 'info'); showZeppConnectModal(); } else { showToast(errorMsg, 'error'); } } } catch (err) { console.error("Zepp sync failed", err); showToast("Sync failed.", 'error'); } finally { if (syncBtn) { syncBtn.innerHTML = originalText; syncBtn.disabled = false; } } } async function loadHealthData(period) { const dateStr = state.currentDate.toISOString().split('T')[0]; const key = `${period}_${dateStr}`; // Cache key needs to include date // if (state.healthCache[key]) { ... } // Simple cache might be tricky if we want fresh data on sync. try { const res = await fetch(`${API_URL}/health/stats?period=${period}&ref_date=${dateStr}`); if (!res.ok) throw new Error("Failed to fetch stats"); const data = await res.json(); state.healthCache[key] = data; renderHealthDashboard(data, period); } catch (err) { console.error("Error loading health data", err); showToast("Failed to load health metrics", "error"); } } function changeDate(delta) { if (!state.currentDate) state.currentDate = new Date(); // Adjust based on period if (state.healthPeriod === 'day') { state.currentDate.setDate(state.currentDate.getDate() + delta); } else if (state.healthPeriod === 'week') { state.currentDate.setDate(state.currentDate.getDate() + (delta * 7)); } else if (state.healthPeriod === 'month') { state.currentDate.setMonth(state.currentDate.getMonth() + delta); } updateDateDisplay(); loadHealthData(state.healthPeriod); // Update workout views with new date switchPeriodViews(state.healthPeriod); } function updateDateDisplay() { const disp = document.getElementById('date-display-text'); if (!disp) return; const dt = state.currentDate; const optionsShort = { month: 'short', day: 'numeric' }; const optionsYear = { month: 'long', year: 'numeric' }; let text = ""; // Check if today const today = new Date(); const isToday = dt.toDateString() === today.toDateString(); if (state.healthPeriod === 'day') { if (isToday) text = "Today"; else text = dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); } else if (state.healthPeriod === 'week') { // Week ending on dt const start = new Date(dt); start.setDate(dt.getDate() - 6); // Format: "Dec 22 - Dec 28" const sStr = start.toLocaleDateString(undefined, optionsShort); const eStr = dt.toLocaleDateString(undefined, optionsShort); text = `${sStr} - ${eStr}`; } else if (state.healthPeriod === 'month') { // Just Month Year? Or "Last 30 Days"? // Backend logic is "Last 30 Days" ending on Date. // It's cleaner to show "Ending Dec 29" for rolling window, or just the date range. // Given typically users expect "Month" to be calendar month, but we implemented rolling. // Let's show range for clarity. const start = new Date(dt); start.setDate(dt.getDate() - 29); const sStr = start.toLocaleDateString(undefined, optionsShort); const eStr = dt.toLocaleDateString(undefined, optionsShort); text = `${sStr} - ${eStr}`; } disp.innerText = text; } // ===== DATE PICKER MODAL ===== function initDatePickerModal() { const modal = document.getElementById('date-picker-modal'); const dateDisplayBtn = document.getElementById('date-display'); const closeBtn = document.getElementById('close-date-picker-btn'); const backdrop = document.getElementById('date-picker-backdrop'); // Single date elements const customDateInput = document.getElementById('custom-date-input'); const singleDateView = document.getElementById('single-date-view'); const singleDateToggle = document.getElementById('single-date-toggle'); // Date range elements const dateRangeView = document.getElementById('date-range-view'); const dateRangeToggle = document.getElementById('date-range-toggle'); const customStartDate = document.getElementById('custom-start-date'); const customEndDate = document.getElementById('custom-end-date'); const periodInfo = document.getElementById('period-info'); const periodInfoText = document.getElementById('period-info-text'); const applyCustomBtn = document.getElementById('apply-custom-date-btn'); const applyBtnText = document.getElementById('apply-btn-text'); const presetBtns = document.querySelectorAll('.preset-btn'); if (!modal || !dateDisplayBtn) return; let isRangeMode = false; // Open modal dateDisplayBtn.addEventListener('click', () => { toggleModal(modal, true); // Set custom date input to current date if (customDateInput && state.currentDate) { customDateInput.value = state.currentDate.toISOString().split('T')[0]; } if (customStartDate && customEndDate && state.currentDate) { const endDate = state.currentDate.toISOString().split('T')[0]; customEndDate.value = endDate; // Set start date based on current period const start = new Date(state.currentDate); if (state.healthPeriod === 'week') { start.setDate(start.getDate() - 6); } else if (state.healthPeriod === 'month') { start.setDate(start.getDate() - 29); } else { start.setDate(start.getDate() - 6); // Default to week } customStartDate.value = start.toISOString().split('T')[0]; } }); // Close modal const closeModal = () => toggleModal(modal, false); if (closeBtn) closeBtn.addEventListener('click', closeModal); if (backdrop) backdrop.addEventListener('click', closeModal); // Toggle between single date and date range if (singleDateToggle) { singleDateToggle.addEventListener('click', () => { isRangeMode = false; singleDateView.classList.remove('hidden'); dateRangeView.classList.add('hidden'); // Update toggle buttons singleDateToggle.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400'); singleDateToggle.classList.remove('text-gray-500', 'dark:text-gray-400'); dateRangeToggle.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400'); dateRangeToggle.classList.add('text-gray-500', 'dark:text-gray-400'); // Update button text applyBtnText.textContent = 'Apply Custom Date'; }); } if (dateRangeToggle) { dateRangeToggle.addEventListener('click', () => { isRangeMode = true; singleDateView.classList.add('hidden'); dateRangeView.classList.remove('hidden'); // Update toggle buttons dateRangeToggle.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400'); dateRangeToggle.classList.remove('text-gray-500', 'dark:text-gray-400'); singleDateToggle.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400'); singleDateToggle.classList.add('text-gray-500', 'dark:text-gray-400'); // Update button text applyBtnText.textContent = 'Apply Date Range'; // Update period info if dates are set updatePeriodInfo(); }); } // Update period info when dates change if (customStartDate) { customStartDate.addEventListener('change', updatePeriodInfo); } if (customEndDate) { customEndDate.addEventListener('change', updatePeriodInfo); } function updatePeriodInfo() { if (!customStartDate.value || !customEndDate.value) { periodInfo.classList.add('hidden'); return; } const start = new Date(customStartDate.value); const end = new Date(customEndDate.value); // Validate dates if (end < start) { periodInfoText.textContent = '⚠️ End date must be after start date'; periodInfo.classList.remove('hidden'); periodInfo.classList.remove('bg-brand-50', 'dark:bg-brand-900/20', 'border-brand-200', 'dark:border-brand-800'); periodInfo.classList.add('bg-red-50', 'dark:bg-red-900/20', 'border-red-200', 'dark:border-red-800'); return; } // Calculate days const diffTime = Math.abs(end - start); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 to include both start and end // Determine view mode let viewMode = ''; let icon = ''; if (diffDays === 1) { viewMode = 'Day View'; icon = '📅'; } else if (diffDays <= 7) { viewMode = 'Week View'; icon = '📊'; } else { viewMode = 'Month View'; icon = '📈'; } periodInfoText.textContent = `${icon} ${diffDays} day${diffDays > 1 ? 's' : ''} selected → ${viewMode}`; periodInfo.classList.remove('hidden', 'bg-red-50', 'dark:bg-red-900/20', 'border-red-200', 'dark:border-red-800'); periodInfo.classList.add('bg-brand-50', 'dark:bg-brand-900/20', 'border-brand-200', 'dark:border-brand-800'); } // Preset buttons presetBtns.forEach(btn => { btn.addEventListener('click', () => { const preset = btn.dataset.preset; applyPreset(preset); closeModal(); }); }); // Custom date/range apply if (applyCustomBtn) { applyCustomBtn.addEventListener('click', () => { if (isRangeMode) { // Date range mode if (!customStartDate.value || !customEndDate.value) { showToast('Please select both start and end dates', 'error'); return; } const start = new Date(customStartDate.value + 'T12:00:00'); const end = new Date(customEndDate.value + 'T12:00:00'); if (end < start) { showToast('End date must be after start date', 'error'); return; } // Calculate period length const diffTime = Math.abs(end - start); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // Set the end date as current date state.currentDate = end; // Determine and set period based on days if (diffDays === 1) { state.healthPeriod = 'day'; } else if (diffDays <= 7) { state.healthPeriod = 'week'; } else { state.healthPeriod = 'month'; } updateTimeframeButtons(state.healthPeriod); updateDateDisplay(); loadHealthData(state.healthPeriod); switchPeriodViews(state.healthPeriod); closeModal(); showToast(`Viewing ${diffDays} day${diffDays > 1 ? 's' : ''} of data`, 'success'); } else { // Single date mode const selectedDate = customDateInput.value; if (selectedDate) { state.currentDate = new Date(selectedDate + 'T12:00:00'); state.healthPeriod = 'day'; updateTimeframeButtons('day'); updateDateDisplay(); loadHealthData(state.healthPeriod); switchPeriodViews(state.healthPeriod); closeModal(); } } }); } } function applyPreset(preset) { const today = new Date(); let targetDate = new Date(); switch (preset) { case 'today': targetDate = today; break; case 'yesterday': targetDate.setDate(today.getDate() - 1); break; case 'this-week': // Set to end of current week (Sunday or today if before Sunday) const dayOfWeek = today.getDay(); const daysUntilSunday = dayOfWeek === 0 ? 0 : 7 - dayOfWeek; targetDate.setDate(today.getDate() + daysUntilSunday); // Switch to week view state.healthPeriod = 'week'; updateTimeframeButtons('week'); break; case 'last-week': // Set to last Sunday const lastSunday = new Date(today); lastSunday.setDate(today.getDate() - today.getDay()); targetDate = lastSunday; // Switch to week view state.healthPeriod = 'week'; updateTimeframeButtons('week'); break; case 'this-month': // Set to end of current month targetDate = new Date(today.getFullYear(), today.getMonth() + 1, 0); // Switch to month view state.healthPeriod = 'month'; updateTimeframeButtons('month'); break; case 'last-30-days': targetDate = today; // Switch to month view state.healthPeriod = 'month'; updateTimeframeButtons('month'); break; } state.currentDate = targetDate; updateDateDisplay(); loadHealthData(state.healthPeriod); switchPeriodViews(state.healthPeriod); } function updateTimeframeButtons(period) { const timeframeBtns = document.querySelectorAll('.timeframe-btn'); timeframeBtns.forEach(btn => { btn.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400'); btn.classList.add('text-gray-500', 'dark:text-gray-400'); if (btn.dataset.period === period) { btn.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400'); btn.classList.remove('text-gray-500', 'dark:text-gray-400'); } }); } function renderHealthDashboard(data, period) { if (!data || data.length === 0) { // Clear/Reset UI to avoid stale data const safeSetText = (id, val) => { const el = document.getElementById(id); if (el) el.innerText = val; }; const safeSetHtml = (id, val) => { const el = document.getElementById(id); if (el) el.innerHTML = val; }; safeSetText('ring-steps', '--'); safeSetText('ring-cals', '--'); safeSetText('val-pai-ring', '--'); safeSetHtml('overview-hr', `-- bpm`); safeSetText('overview-sleep-dur', '--h --m'); safeSetText('overview-sleep-score', '--'); safeSetText('val-weight', '-- kg'); safeSetText('val-weight-gauge', '--'); safeSetText('val-bmi', '--'); safeSetText('val-bmi-gauge', '--'); safeSetText('readiness-score', '--'); safeSetText('val-rhr', '--'); safeSetText('val-hrv', '--'); // Clear Charts if (state.charts.health.rings) { state.charts.health.rings.destroy(); state.charts.health.rings = null; } if (state.charts.health.hrMini) { state.charts.health.hrMini.destroy(); state.charts.health.hrMini = null; } return; } // --- Calcs --- const latest = data[data.length - 1]; // Latest day let avgHr = 0, avgSteps = 0, avgSleep = 0, avgPai = 0, avgRhr = 0, avgHrv = 0; // Averages over the period const count = data.length; data.forEach(d => { avgHr += d.avg_heart_rate || 0; avgSteps += d.step_count || 0; avgSleep += d.sleep_duration_minutes || 0; avgPai += d.pai_score || 0; avgRhr += d.resting_heart_rate || 0; avgHrv += d.hrv || 0; }); avgHr = Math.round(avgHr / count); avgSteps = Math.round(avgSteps / count); avgSleep = Math.round(avgSleep / count); avgPai = (avgPai / count).toFixed(1); avgRhr = Math.round(avgRhr / count); avgHrv = Math.round(avgHrv / count); // --- Tab: Overview --- // Rings renderRingsChart(avgSteps, avgPai, 500); // 500 active cals goal placeholder document.getElementById('ring-steps').innerText = latest.step_count?.toLocaleString() || '--'; document.getElementById('ring-cals').innerText = Math.round(latest.calories_burned || 0); document.getElementById('val-pai-ring').innerText = Math.round(latest.pai_score || 0); // Mini HR Chart document.getElementById('overview-hr').innerHTML = `${latest.avg_heart_rate || '--'} bpm`; renderMiniHrChart(data); // Sleep Summary const sh = Math.floor((latest.sleep_duration_minutes || 0) / 60); const sm = (latest.sleep_duration_minutes || 0) % 60; document.getElementById('overview-sleep-dur').innerText = `${sh}h ${sm}m`; document.getElementById('overview-sleep-score').innerText = calculateSleepScore(latest); // Fake score const sleepGoal = 8 * 60; // 8 hours const sleepPct = Math.min(100, ((latest.sleep_duration_minutes || 0) / sleepGoal) * 100); const sleepBar = document.getElementById('overview-sleep-bar'); if (sleepBar) sleepBar.style.width = `${sleepPct}%`; // Metrics Row - Update both old and new gauge widgets // IMPORTANT: Search for latest valid weight in ALL data, not just latest entry const heightM = 1.75; // Default height for BMI calculation // Process all data to add normalized weight and BMI const dataWithBMI = data.map(d => { const w = d.weight || d.weight_kg; return { ...d, weight_norm: w, bmi: w ? (w / (heightM * heightM)) : null }; }); // Find the LATEST entry that has a valid weight (searching backwards) const reversedData = [...dataWithBMI].reverse(); const latestWithWeight = reversedData.find(d => d.weight_norm && d.weight_norm > 0); if (latestWithWeight) { const weightVal = latestWithWeight.weight_norm; const bmi = latestWithWeight.bmi; // Update text displays const weightEl = document.getElementById('val-weight'); const bmiEl = document.getElementById('val-bmi'); if (weightEl) weightEl.innerText = `${weightVal.toFixed(1)} kg`; if (bmiEl) bmiEl.innerText = bmi.toFixed(1); // Initialize gauges with the latest valid data initializeMetricGauges(dataWithBMI, latestWithWeight); } else { // No weight data found at all const weightEl = document.getElementById('val-weight'); const bmiEl = document.getElementById('val-bmi'); if (weightEl) weightEl.innerText = '-- kg'; if (bmiEl) bmiEl.innerText = '--'; // Reset gauges display const weightGaugeEl = document.getElementById('val-weight-gauge'); const bmiGaugeEl = document.getElementById('val-bmi-gauge'); if (weightGaugeEl) weightGaugeEl.innerText = '--'; if (bmiGaugeEl) bmiGaugeEl.innerText = '--'; } // --- Tab: Readiness --- // Mock Readiness Calculation (0-100) // Formula: Weight sleep, HRV, RHR. // Higher HRV + Lower RHR + Good Sleep = High Readiness. let readiness = 85; // Simple dynamic mock: if (latest.hrv) readiness = Math.min(100, Math.max(0, 50 + (latest.hrv * 0.5) - ((latest.resting_heart_rate - 60) * 0.5))); document.getElementById('readiness-score').innerText = Math.round(readiness); const readinessRing = document.getElementById('readiness-ring'); // Circle circumference approx 440 (r=70 * 2 * PI = 439.8) const offset = 440 - (440 * (readiness / 100)); if (readinessRing) readinessRing.style.strokeDashoffset = offset; // Readiness Details document.getElementById('val-rhr').innerText = latest.resting_heart_rate || '--'; document.getElementById('val-hrv').innerText = latest.hrv ? Math.round(latest.hrv) : '--'; renderRhrChart(data); renderHrvChart(data); // --- Tab: Sleep --- // Total Sleep Duration const total = latest.sleep_duration_minutes || 0; const totalH = Math.floor(total / 60); const totalM = total % 60; document.getElementById('sleep-day-total').innerText = `${totalH}h ${totalM}m`; // Deep/Light/REM const dH = Math.floor((latest.deep_sleep_minutes || 0) / 60); const dM = (latest.deep_sleep_minutes || 0) % 60; document.getElementById('val-deep').innerText = `${dH}h ${dM}m`; // We don't have REM/Light separated in Zepp import perfectly yet? // We have deep and awake. Total - Deep - Awake = Light+REM. const awake = latest.awake_duration_minutes || 0; document.getElementById('val-light').innerText = `${Math.floor((latest.light_sleep_minutes || 0) / 60)}h ${(latest.light_sleep_minutes || 0) % 60}m`; document.getElementById('val-rem').innerText = `${Math.floor((latest.rem_sleep_minutes || 0) / 60)}h ${(latest.rem_sleep_minutes || 0) % 60}m`; document.getElementById('val-awake').innerText = `${latest.awake_duration_minutes || 0}m`; document.getElementById('val-sleep-hr').innerText = latest.avg_heart_rate || '--'; // Using avg daily as proxy for sleep HR for now renderHypnogram(latest); // --- Tab: Exertion --- document.getElementById('val-exertion-pai').innerText = Math.round(latest.pai_score || 0); renderExertionChart(data); // --- Period-Specific Rendering --- // Transform data for period views (expects daily array format) const dailyData = data.map(d => ({ date: d.date, steps: d.step_count || 0, calories: d.calories_burned || 0, pai: d.pai_score || 0, sleep_duration: d.sleep_duration_minutes || 0, resting_hr: d.resting_heart_rate || 0, avg_hr: d.avg_heart_rate || 0, hrv: d.hrv || 0, deep_sleep: d.deep_sleep_minutes || 0, light_sleep: d.light_sleep_minutes || 0, rem_sleep: d.rem_sleep_minutes || 0, awake: d.awake_duration_minutes || 0 })); if (period === 'week' && dailyData.length > 0) { renderWeekOverview({ daily: dailyData }); } else if (period === 'month' && dailyData.length > 0) { renderMonthOverview({ daily: dailyData }); } // Update HR min/max/avg for day view if (period === 'day' && latest) { const hrMinEl = document.getElementById('hr-min-day'); const hrAvgEl = document.getElementById('hr-avg-day'); const hrMaxEl = document.getElementById('hr-max-day'); if (hrMinEl) hrMinEl.innerText = latest.min_heart_rate || '--'; if (hrAvgEl) hrAvgEl.innerText = latest.avg_heart_rate || '--'; if (hrMaxEl) hrMaxEl.innerText = latest.max_heart_rate || '--'; // Update distance in ring const distEl = document.getElementById('ring-distance'); if (distEl) distEl.innerText = `${((latest.distance_meters || 0) / 1000).toFixed(1)} km`; } } function calculateSleepScore(data) { if (!data.sleep_duration_minutes) return '--'; let score = 70; // Duration if (data.sleep_duration_minutes > 420) score += 10; if (data.sleep_duration_minutes > 480) score += 10; // Deep Sleep if (data.deep_sleep_minutes > 60) score += 10; // Awake if (data.awake_duration_minutes < 30) score += 10; return Math.min(100, score); } // --- Chart Rendering Functions --- // Common options const commonChartOptions = (isDark) => ({ responsive: true, maintainAspectRatio: false, scales: { x: { display: false }, y: { display: false } }, plugins: { legend: { display: false }, tooltip: { enabled: true } }, elements: { point: { radius: 0, hitRadius: 10 } } }); function renderRingsChart(steps, pai, cals) { const ctx = document.getElementById('chart-rings').getContext('2d'); if (state.charts.health.rings) state.charts.health.rings.destroy(); // Doughnut chart with multiple datasets (rings) state.charts.health.rings = new Chart(ctx, { type: 'doughnut', data: { labels: ['Steps', 'PAI', 'Calories'], datasets: [ { data: [steps, 10000 - steps], // Steps (Goal 10k) backgroundColor: ['#3b82f6', '#1e293b'], borderWidth: 0, circumference: 360, rotation: 0 }, { data: [pai, 100 - pai], // PAI (Goal 100) backgroundColor: ['#f97316', '#1e293b'], borderWidth: 0, circumference: 360, rotation: 0 }, { data: [cals, 500 - cals], // Cals (Goal 500) backgroundColor: ['#10b981', '#1e293b'], borderWidth: 0, circumference: 360, rotation: 0 } ] }, options: { responsive: true, cutout: '60%', // Thickness maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { enabled: false } }, animation: { animateScale: true, animateRotate: true } } }); // Chart.js standard doughnut doesn't do concentric rings easily without plugin. // For MVP, single ring for PAI or simplify. // Let's do a simple implementation: Just one ring for main metric (Steps) or PAI. // Re-doing as Single Ring PAI for simplicity in this iteration to ensure it looks good. state.charts.health.rings.destroy(); state.charts.health.rings = new Chart(ctx, { type: 'doughnut', data: { labels: ['PAI', 'Remaining'], datasets: [{ data: [pai, Math.max(0, 100 - pai)], backgroundColor: ['#f97316', '#f3f4f6'], borderColor: ['#f97316', 'transparent'], borderWidth: 0, borderRadius: 20 }] }, options: { responsive: true, cutout: '80%', maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { enabled: false } } } }); } function renderMiniHrChart(data) { const ctx = document.getElementById('chart-mini-hr').getContext('2d'); if (state.charts.health.miniHr) state.charts.health.miniHr.destroy(); const hrData = data.map(d => d.avg_heart_rate); const labels = data.map(d => d.date); const isDark = document.documentElement.classList.contains('dark'); // Calc min/max for scale to avoid floating line const validHr = hrData.filter(v => v); const minVal = validHr.length ? Math.min(...validHr) : 0; const maxVal = validHr.length ? Math.max(...validHr) : 100; const padding = 5; state.charts.health.miniHr = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [{ data: hrData, borderColor: '#ef4444', borderWidth: 2, tension: 0.4, pointRadius: 0 }] }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { display: false }, y: { display: false, min: Math.max(0, minVal - padding), max: maxVal + padding } }, plugins: { legend: { display: false } } } }); } function renderRhrChart(data) { if (!data || data.length === 0) return; const ctx = document.getElementById('chart-rhr'); if (!ctx) return; const context = ctx.getContext('2d'); if (state.charts.health.rhr) state.charts.health.rhr.destroy(); state.charts.health.rhr = new Chart(context, { type: 'line', data: { labels: data.map(d => d.date), datasets: [{ data: data.map(d => d.resting_heart_rate || null), borderColor: '#10b981', borderWidth: 2, tension: 0.4, pointRadius: 2 }] }, options: { ...commonChartOptions(), scales: { x: { display: false }, y: { display: false } } } }); } function renderHrvChart(data) { if (!data || data.length === 0) return; const ctx = document.getElementById('chart-hrv'); if (!ctx) return; const context = ctx.getContext('2d'); if (state.charts.health.hrv) state.charts.health.hrv.destroy(); state.charts.health.hrv = new Chart(context, { type: 'bar', data: { labels: data.map(d => d.date), datasets: [{ data: data.map(d => d.hrv || null), backgroundColor: '#3b82f6', borderRadius: 2 }] }, options: { ...commonChartOptions(), scales: { x: { display: false }, y: { display: false } } } }); } function renderHypnogram(dayData) { // Safety check if (!dayData) { console.warn('renderHypnogram: No data provided'); return; } const ctx = document.getElementById('chart-hypnogram'); if (!ctx) return; const context = ctx.getContext('2d'); if (state.charts.health.hypno) state.charts.health.hypno.destroy(); // Since we don't have time-series stages, we visualize composition as a stacked bar const placeholder = document.getElementById('hypno-placeholder'); if (placeholder) placeholder.style.display = 'none'; // Mock discrete blocks for "Hypnogram" feel if we don't have real trace // Or just simple stacked bar. Stacked horizontal bar looks like a timeline. const deep = dayData.deep_sleep_minutes || 0; const light = dayData.light_sleep_minutes || 0; const rem = dayData.rem_sleep_minutes || 0; const awake = dayData.awake_duration_minutes || 0; state.charts.health.hypno = new Chart(context, { type: 'doughnut', data: { labels: ['Deep', 'Light', 'REM', 'Awake'], datasets: [ { data: [deep, light, rem, awake], backgroundColor: ['#9333ea', '#60a5fa', '#facc15', '#d1d5db'], borderWidth: 0, hoverOffset: 10 } ] }, options: { responsive: true, maintainAspectRatio: false, cutout: '70%', plugins: { legend: { display: false } // We have custom legend below } } }); } function renderExertionChart(data) { if (!data || data.length === 0) { console.warn('renderExertionChart: No data provided'); return; } const ctx = document.getElementById('chart-exertion'); if (!ctx) return; const context = ctx.getContext('2d'); if (state.charts.health.exertion) state.charts.health.exertion.destroy(); const todayStr = state.currentDate ? state.currentDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0]; const colors = data.map(d => d.date === todayStr ? '#ffdbcb' : '#f97316'); // Highlight today with lighter orange/whiteish state.charts.health.exertion = new Chart(context, { type: 'bar', data: { labels: data.map(d => new Date(d.date).toLocaleDateString('en-US', { weekday: 'short' })), datasets: [{ label: 'PAI', data: data.map(d => d.pai_score), backgroundColor: colors, borderRadius: 4 }] }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { grid: { display: false } }, y: { grid: { color: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb' }, title: { display: true, text: 'Score' } } }, plugins: { legend: { display: false } } } }); } // --- Toast Notification --- function showToast(message, type = 'success') { const container = document.getElementById('toast-container'); if (!container) return; // Icon logic let iconHtml = ''; let colorClass = ''; if (type === 'success') { colorClass = 'text-green-500 bg-green-100 dark:bg-green-800/30 dark:text-green-400'; iconHtml = ''; } else if (type === 'error') { colorClass = 'text-red-500 bg-red-100 dark:bg-red-800/30 dark:text-red-400'; iconHtml = ''; } else { colorClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800/30 dark:text-blue-400'; iconHtml = ''; } const toast = document.createElement('div'); toast.className = 'pointer-events-auto flex items-center w-full max-w-sm p-4 text-gray-500 bg-white rounded-xl shadow-lg dark:text-gray-300 dark:bg-gray-800 border border-gray-100 dark:border-gray-700 transform transition-all duration-300 translate-x-full opacity-0'; toast.innerHTML = `
${iconHtml}
${message}
`; // Close button logic const closeBtn = toast.querySelector('button'); closeBtn.addEventListener('click', () => removeToast(toast)); container.appendChild(toast); // Animate In requestAnimationFrame(() => { toast.classList.remove('translate-x-full', 'opacity-0'); }); // Auto dismiss setTimeout(() => { removeToast(toast); }, 5000); } function removeToast(el) { el.classList.add('translate-x-full', 'opacity-0'); // Remove after transition setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 300); } // --- Metric Gauges & Details Modal --- // State for metric details const metricState = { currentMetric: 'weight', currentPeriod: 'recent', historyChart: null, gaugeWeight: null, gaugeBMI: null }; // Create semi-circular gauge function createGauge(canvasId, value, min, max, colorStops) { const canvas = document.getElementById(canvasId); if (!canvas) return null; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return null; // Set internal size to match physical pixels canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; const width = canvas.width; const height = canvas.height; const centerX = width / 2; // Position the arc in the upper portion to leave space for text below const centerY = height * 0.65; // Move center down slightly (was height - 10*dpr) const radius = Math.max(0, Math.min(width / 2, height * 0.65) - (20 * dpr)); // Clear ctx.clearRect(0, 0, width, height); // Background arc ctx.beginPath(); ctx.arc(centerX, centerY, radius, Math.PI, 2 * Math.PI, false); ctx.lineWidth = 12 * dpr; ctx.strokeStyle = '#1f2937'; ctx.lineCap = 'round'; ctx.stroke(); // Create gradient for colored arc const gradient = ctx.createLinearGradient(centerX - radius, centerY, centerX + radius, centerY); colorStops.forEach(stop => { gradient.addColorStop(stop.offset, stop.color); }); // Calculate angle based on value const percentage = Math.min(Math.max((value - min) / (max - min), 0), 1); const angle = Math.PI + (percentage * Math.PI); // Colored arc ctx.beginPath(); ctx.arc(centerX, centerY, radius, Math.PI, angle, false); ctx.lineWidth = 12 * dpr; ctx.strokeStyle = gradient; ctx.lineCap = 'round'; ctx.stroke(); // Indicator (Diamond/Needle) const indicatorAngle = angle; const innerR = radius - (15 * dpr); const outerR = radius + (15 * dpr); const x1 = centerX + Math.cos(indicatorAngle) * innerR; const y1 = centerY + Math.sin(indicatorAngle) * innerR; const x2 = centerX + Math.cos(indicatorAngle) * outerR; const y2 = centerY + Math.sin(indicatorAngle) * outerR; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.lineWidth = 4 * dpr; ctx.strokeStyle = '#ffffff'; ctx.lineCap = 'round'; ctx.stroke(); // Outer point ctx.beginPath(); ctx.arc(x2, y2, 4 * dpr, 0, Math.PI * 2); ctx.fillStyle = '#ffffff'; ctx.fill(); return { canvas, ctx, value, min, max }; } // Initialize gauges when health data is loaded function initializeMetricGauges(data, specificLatest = null) { if (!data || data.length === 0) return; const latest = specificLatest || data[data.length - 1]; // Weight gauge (assuming range 100-200 lbs) const weightVal = latest.weight_norm || latest.weight || latest.weight_kg; if (weightVal) { const weightLbs = (weightVal * 2.20462).toFixed(1); // Vibrant color gradient: Cyan -> Green -> Yellow -> Red const colorStops = [ { offset: 0, color: '#06b6d4' }, // Cyan (100 lbs) { offset: 0.3, color: '#10b981' }, // Green (130 lbs) { offset: 0.7, color: '#fbbf24' }, // Yellow (170 lbs) { offset: 1, color: '#ef4444' } // Red (200 lbs) ]; metricState.gaugeWeight = createGauge('gauge-weight', parseFloat(weightLbs), 100, 200, colorStops); const weightGaugeEl = document.getElementById('val-weight-gauge'); if (weightGaugeEl) weightGaugeEl.innerText = weightLbs; const weightDateEl = document.getElementById('weight-date'); if (weightDateEl) weightDateEl.innerText = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); // Determine status with color const bmi = latest.bmi || 0; let status = 'BALANCED'; let statusColor = 'text-cyan-400'; if (bmi < 18.5) { status = 'UNDERWEIGHT'; statusColor = 'text-purple-400'; } else if (bmi >= 18.5 && bmi < 25) { status = 'BALANCED'; statusColor = 'text-cyan-400'; } else if (bmi >= 25 && bmi < 30) { status = 'OVERWEIGHT'; statusColor = 'text-yellow-400'; } else if (bmi >= 30) { status = 'OBESE'; statusColor = 'text-red-400'; } const weightStatusEl = document.getElementById('weight-status'); if (weightStatusEl) { weightStatusEl.innerText = status; weightStatusEl.className = `text-sm ${statusColor} uppercase tracking-wider mt-2 font-semibold`; } } // BMI gauge (assuming range 15-35) if (latest.bmi) { const bmi = latest.bmi.toFixed(1); // Vibrant 5-color gradient: Purple -> Cyan -> Green -> Yellow -> Red const colorStops = [ { offset: 0, color: '#a855f7' }, // Purple (15) { offset: 0.175, color: '#06b6d4' }, // Cyan (18.5) { offset: 0.5, color: '#10b981' }, // Green (25) { offset: 0.75, color: '#fbbf24' }, // Yellow (30) { offset: 1, color: '#ef4444' } // Red (35) ]; metricState.gaugeBMI = createGauge('gauge-bmi', parseFloat(bmi), 15, 35, colorStops); const bmiGaugeEl = document.getElementById('val-bmi-gauge'); if (bmiGaugeEl) bmiGaugeEl.innerText = bmi; const bmiDateEl = document.getElementById('bmi-date'); if (bmiDateEl) bmiDateEl.innerText = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); // Determine BMI status with appropriate color let status = 'NORMAL'; let statusColor = 'text-green-400'; if (bmi < 18.5) { status = 'UNDERWEIGHT'; statusColor = 'text-cyan-400'; } else if (bmi >= 18.5 && bmi < 25) { status = 'NORMAL'; statusColor = 'text-green-400'; } else if (bmi >= 25 && bmi < 30) { status = 'OVERWEIGHT'; statusColor = 'text-yellow-400'; } else if (bmi >= 30) { status = 'OBESE'; statusColor = 'text-red-400'; } const bmiStatusEl = document.getElementById('bmi-status'); if (bmiStatusEl) { bmiStatusEl.innerText = status; bmiStatusEl.className = `text-sm ${statusColor} uppercase tracking-wider mt-2 font-semibold`; } } } // Open metric details modal window.openMetricDetails = function (metric) { metricState.currentMetric = metric; metricState.currentPeriod = 'recent'; const modal = document.getElementById('metric-details-modal'); const title = document.getElementById('metric-title'); // Update title title.innerText = metric.toUpperCase(); // Show/hide specific sections const trendsSection = document.getElementById('metric-trends'); const bmiInfoSection = document.getElementById('metric-bmi-info'); if (metric === 'weight') { trendsSection.classList.remove('hidden'); bmiInfoSection.classList.add('hidden'); } else { trendsSection.classList.add('hidden'); bmiInfoSection.classList.remove('hidden'); } // Reset period buttons document.querySelectorAll('.metric-period-btn').forEach(btn => { btn.classList.remove('bg-brand-600', 'text-white'); btn.classList.add('text-gray-400'); }); const recentBtn = document.querySelector('.metric-period-btn[data-period="recent"]'); if (recentBtn) { recentBtn.classList.add('bg-brand-600', 'text-white'); recentBtn.classList.remove('text-gray-400'); } // Show modal toggleModal(modal, true); // Load data loadMetricHistory(metric, 'recent'); }; // Load metric history async function loadMetricHistory(metric, period) { try { const dateStr = state.currentDate ? state.currentDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0]; // Always fetch from API for the specific period to get full history (especially for 'year') const apiPeriod = (period === 'recent') ? 'week' : period; const res = await fetch(`${API_URL}/health/stats?period=${apiPeriod}&ref_date=${dateStr}`); if (!res.ok) throw new Error("Failed to fetch history"); const data = await res.json(); if (!data || data.length === 0) { console.warn('No health data available'); return; } const heightM = 1.75; // Default height for BMI calc if missing // Add BMI to data if missing const processedData = data.map(d => { const w = d.weight || d.weight_kg; return { ...d, weight_norm: w, bmi: d.bmi || (w ? (w / (heightM * heightM)) : null) }; }); // Update current value display (latest valid value in the dataset) const validEntries = processedData.filter(d => metric === 'weight' ? d.weight_norm > 0 : d.bmi > 0); const latest = validEntries.length > 0 ? validEntries[validEntries.length - 1] : processedData[processedData.length - 1]; if (metric === 'weight' && (latest.weight_norm || latest.weight)) { const w = latest.weight_norm || latest.weight; const weightLbs = (w * 2.20462).toFixed(1); document.getElementById('metric-current-value').innerText = weightLbs + ' lbs'; } else if (metric === 'bmi' && latest.bmi) { document.getElementById('metric-current-value').innerText = latest.bmi.toFixed(1); } document.getElementById('metric-current-date').innerText = new Date(latest.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); // Calculate stats based on ALL data for the period const values = processedData.map(d => metric === 'weight' ? (d.weight_norm ? d.weight_norm * 2.20462 : null) : d.bmi).filter(v => v); if (values.length > 0) { const avg = (values.reduce((a, b) => a + b, 0) / values.length).toFixed(1); const max = Math.max(...values).toFixed(1); const min = Math.min(...values).toFixed(1); document.getElementById('metric-avg').innerText = avg + (metric === 'weight' ? ' lbs' : ''); document.getElementById('metric-max').innerText = max + (metric === 'weight' ? ' lbs' : ''); document.getElementById('metric-min').innerText = min + (metric === 'weight' ? ' lbs' : ''); } // Render chart with full history renderMetricChart(processedData, metric); } catch (err) { console.error('Error loading metric history:', err); showToast("Failed to load history data", "error"); } } // Render metric history chart function renderMetricChart(data, metric) { const canvas = document.getElementById('metric-history-chart'); if (!canvas) return; const ctx = canvas.getContext('2d'); // Destroy existing chart if (metricState.historyChart) { metricState.historyChart.destroy(); } // Prepare data const labels = data.map(d => new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); const values = data.map(d => { if (metric === 'weight') { const w = d.weight_norm || d.weight || d.weight_kg; return w ? (w * 2.20462).toFixed(1) : null; } else { return d.bmi ? d.bmi.toFixed(1) : null; } }); const isDark = document.documentElement.classList.contains('dark'); metricState.historyChart = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [{ label: metric === 'weight' ? 'Weight (lbs)' : 'BMI', data: values, borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.1)', tension: 0.4, fill: true, pointRadius: 4, pointBackgroundColor: '#10b981', pointBorderColor: '#fff', pointBorderWidth: 2, pointHoverRadius: 6 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: isDark ? '#1f2937' : '#ffffff', titleColor: isDark ? '#ffffff' : '#000000', bodyColor: isDark ? '#9ca3af' : '#6b7280', borderColor: isDark ? '#374151' : '#e5e7eb', borderWidth: 1, padding: 12, displayColors: false } }, scales: { y: { beginAtZero: false, grid: { color: isDark ? '#374151' : '#e5e7eb', drawBorder: false }, ticks: { color: isDark ? '#9ca3af' : '#6b7280', font: { size: 11 } } }, x: { grid: { display: false }, ticks: { color: isDark ? '#9ca3af' : '#6b7280', font: { size: 11 }, maxRotation: 45, minRotation: 45 } } } } }); } // Initialize metric modal handlers function initMetricModal() { const modal = document.getElementById('metric-details-modal'); const closeBtn = document.getElementById('close-metric-modal-btn'); const backBtn = document.getElementById('metric-back-btn'); const backdrop = document.getElementById('metric-modal-backdrop'); if (!modal) return; const closeModal = () => { toggleModal(modal, false); if (metricState.historyChart) { metricState.historyChart.destroy(); metricState.historyChart = null; } }; if (closeBtn) closeBtn.addEventListener('click', closeModal); if (backBtn) backBtn.addEventListener('click', closeModal); if (backdrop) backdrop.addEventListener('click', closeModal); // Period buttons document.querySelectorAll('.metric-period-btn').forEach(btn => { btn.addEventListener('click', () => { const period = btn.dataset.period; metricState.currentPeriod = period; // Update button states document.querySelectorAll('.metric-period-btn').forEach(b => { b.classList.remove('bg-brand-600', 'text-white'); b.classList.add('text-gray-400'); }); btn.classList.add('bg-brand-600', 'text-white'); btn.classList.remove('text-gray-400'); // Reload data loadMetricHistory(metricState.currentMetric, period); }); }); // History data button (placeholder) const historyBtn = document.getElementById('history-data-btn'); if (historyBtn) { historyBtn.addEventListener('click', () => { showToast('History data export coming soon!', 'info'); }); } // Add entry button (placeholder) const addBtn = document.getElementById('metric-add-btn'); if (addBtn) { addBtn.addEventListener('click', () => { showToast('Manual entry coming soon!', 'info'); }); } } // Initialize on DOMContentLoaded document.addEventListener('DOMContentLoaded', () => { initMetricModal(); initWorkoutsTab(); // Initialize period views to match default state switchPeriodViews(state.healthPeriod || 'day'); }); // ===== PERIOD-ADAPTIVE VIEW SYSTEM ===== // Chart instances for period views const periodCharts = { weekPai: null, monthSteps: null, monthSleep: null }; /** * Switch between Day/Week/Month views within each tab */ function switchPeriodViews(period) { const periodMap = { 'day': 'day', 'week': 'week', 'month': 'month' }; const activePeriod = periodMap[period] || 'day'; // Find all tabs that have period-specific views const tabs = ['overview', 'workouts', 'readiness', 'sleep', 'exertion']; tabs.forEach(tab => { const dayView = document.getElementById(`${tab}-day`); const weekView = document.getElementById(`${tab}-week`); const monthView = document.getElementById(`${tab}-month`); [dayView, weekView, monthView].forEach(v => { if (v) v.classList.add('hidden'); }); const activeView = document.getElementById(`${tab}-${activePeriod}`); if (activeView) { activeView.classList.remove('hidden'); } else { // Fallback to day view if specific period view doesn't exist if (dayView) dayView.classList.remove('hidden'); } }); } /** * Render week overview - PAI bar chart with goal line + mini rings */ function renderWeekOverview(data) { if (!data || !data.daily) return; const dailyData = data.daily || []; const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; // Calculate totals let totalPai = 0; let totalSteps = 0; let totalCals = 0; let totalSleep = 0; let totalRhr = 0; let countRhr = 0; dailyData.forEach((d, i) => { totalPai += d.pai || 0; totalSteps += d.steps || 0; totalCals += d.calories || 0; totalSleep += d.sleep_duration || 0; if (d.resting_hr) { totalRhr += d.resting_hr; countRhr++; } // Update mini rings const ringEl = document.getElementById(`mini-ring-${i}`); if (ringEl) { const ring = ringEl.querySelector('div'); const goalReached = (d.steps || 0) >= 8000; // Example goal if (ring) { ring.className = `w-10 h-10 mx-auto rounded-full border-4 ${goalReached ? 'border-green-500 bg-green-500/20' : 'border-gray-600'}`; } } }); // Update summary values document.getElementById('week-total-pai').innerText = Math.round(totalPai); document.getElementById('week-avg-steps').innerText = Math.round(totalSteps / Math.max(dailyData.length, 1)).toLocaleString(); document.getElementById('week-avg-cals').innerText = Math.round(totalCals / Math.max(dailyData.length, 1)).toLocaleString(); const avgSleepHrs = (totalSleep / Math.max(dailyData.length, 1)) / 60; document.getElementById('week-avg-sleep').innerText = `${Math.floor(avgSleepHrs)}h ${Math.round((avgSleepHrs % 1) * 60)}m`; document.getElementById('week-avg-rhr').innerText = countRhr > 0 ? Math.round(totalRhr / countRhr) : '--'; // PAI Bar Chart const ctx = document.getElementById('chart-week-pai'); if (!ctx) return; if (periodCharts.weekPai) periodCharts.weekPai.destroy(); const isDark = document.documentElement.classList.contains('dark'); const paiData = dailyData.map(d => d.pai || 0); const labels = dailyData.map((d, i) => days[i] || `Day ${i + 1}`); periodCharts.weekPai = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [{ label: 'PAI Score', data: paiData, backgroundColor: paiData.map(v => v >= 15 ? '#10b981' : '#f97316'), borderRadius: 8, barThickness: 40 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, annotation: { annotations: { goalLine: { type: 'line', yMin: 15, yMax: 15, borderColor: '#ef4444', borderWidth: 2, borderDash: [6, 6], label: { content: 'Daily Goal', enabled: true, position: 'end' } } } } }, scales: { y: { beginAtZero: true, max: Math.max(...paiData, 30), grid: { color: isDark ? '#374151' : '#e5e7eb' }, ticks: { color: isDark ? '#9ca3af' : '#6b7280' } }, x: { grid: { display: false }, ticks: { color: isDark ? '#9ca3af' : '#6b7280' } } } } }); } /** * Render month overview - Activity heatmap + trend charts */ function renderMonthOverview(data) { if (!data || !data.daily) return; const dailyData = data.daily || []; const container = document.getElementById('heatmap-container'); if (!container) return; // Clear existing container.innerHTML = ''; // Add day headers const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; dayLabels.forEach(day => { const header = document.createElement('div'); header.className = 'text-center text-xs text-gray-400 font-medium py-1'; header.textContent = day; container.appendChild(header); }); // Calculate max for intensity scaling const maxSteps = Math.max(...dailyData.map(d => d.steps || 0), 10000); let activeDays = 0; let restDays = 0; // Create heatmap cells dailyData.forEach(d => { const steps = d.steps || 0; const intensity = steps / maxSteps; let colorClass = 'bg-gray-700'; if (intensity > 0.1) colorClass = 'bg-green-900'; if (intensity > 0.3) colorClass = 'bg-green-700'; if (intensity > 0.5) colorClass = 'bg-green-500'; if (intensity > 0.7) colorClass = 'bg-green-400'; if (steps > 3000) activeDays++; else restDays++; const cell = document.createElement('div'); cell.className = `aspect-square rounded ${colorClass} cursor-pointer hover:ring-2 hover:ring-white/50 transition-all`; cell.title = `${d.date}: ${steps.toLocaleString()} steps`; container.appendChild(cell); }); document.getElementById('month-active-days').innerText = activeDays; document.getElementById('month-rest-days').innerText = restDays; // Render trend charts renderMonthTrendCharts(dailyData); } /** * Render monthly trend line charts */ function renderMonthTrendCharts(dailyData) { const isDark = document.documentElement.classList.contains('dark'); const labels = dailyData.map((d, i) => i + 1); // Steps trend const stepsCtx = document.getElementById('chart-month-steps'); if (stepsCtx) { if (periodCharts.monthSteps) periodCharts.monthSteps.destroy(); periodCharts.monthSteps = new Chart(stepsCtx, { type: 'line', data: { labels: labels, datasets: [{ label: 'Steps', data: dailyData.map(d => d.steps || 0), borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', fill: true, tension: 0.4, pointRadius: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: isDark ? '#374151' : '#e5e7eb' } }, x: { grid: { display: false }, ticks: { maxTicksLimit: 5 } } } } }); } // Sleep trend const sleepCtx = document.getElementById('chart-month-sleep'); if (sleepCtx) { if (periodCharts.monthSleep) periodCharts.monthSleep.destroy(); periodCharts.monthSleep = new Chart(sleepCtx, { type: 'bar', data: { labels: labels, datasets: [{ label: 'Sleep (hrs)', data: dailyData.map(d => (d.sleep_duration || 0) / 60), backgroundColor: dailyData.map(d => { const hrs = (d.sleep_duration || 0) / 60; if (hrs < 6) return '#ef4444'; if (hrs < 7) return '#f59e0b'; return '#10b981'; }), borderRadius: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, max: 12, grid: { color: isDark ? '#374151' : '#e5e7eb' } }, x: { grid: { display: false }, ticks: { maxTicksLimit: 5 } } } } }); } } // --- Period View Switching --- function switchPeriodViews(period) { // Map period to view suffix const viewMap = { 'day': 'day', 'week': 'week', 'month': 'month' }; const suffix = viewMap[period] || 'day'; // Get all tabs const tabs = ['overview', 'readiness', 'sleep', 'exertion', 'workouts']; tabs.forEach(tab => { // Hide all period views for this tab ['day', 'week', 'month'].forEach(p => { const viewEl = document.getElementById(`${tab}-${p}`); if (viewEl) { viewEl.classList.add('hidden'); } }); // Show the selected period view const activeView = document.getElementById(`${tab}-${suffix}`); if (activeView) { activeView.classList.remove('hidden'); } }); // Render period-specific data for workouts if (period === 'month') { renderWorkoutsMonth(); } else if (period === 'week') { renderWorkoutsWeek(); } else { renderWorkoutsDay(); } } // --- Workouts Tab --- const workoutState = { workouts: [], summary: null, charts: { types: null, weekly: null } }; function initWorkoutsTab() { // Filter handler const filterEl = document.getElementById('workout-filter'); if (filterEl) { filterEl.addEventListener('change', () => { renderWorkoutsList(workoutState.workouts, filterEl.value); }); } } async function loadWorkoutsData() { try { // Load workouts list const workoutsRes = await fetch(`${API_URL}/health/workouts?limit=100`); if (workoutsRes.ok) { workoutState.workouts = await workoutsRes.json(); } // Load summary const summaryRes = await fetch(`${API_URL}/health/workouts/summary`); if (summaryRes.ok) { workoutState.summary = await summaryRes.json(); } renderWorkoutsTab(); } catch (err) { console.error('Error loading workouts:', err); showToast('Failed to load workouts', 'error'); } } function renderWorkoutsTab() { const summary = workoutState.summary; const workouts = workoutState.workouts; if (!summary) return; // Summary cards document.getElementById('workout-total').innerText = summary.total_workouts || 0; // Calculate totals from by_activity_type let totalDuration = 0; let totalDistance = 0; let totalCalories = 0; summary.by_activity_type.forEach(t => { totalDuration += t.total_duration_seconds || 0; totalDistance += t.total_distance_meters || 0; totalCalories += t.total_calories || 0; }); // Format duration const hours = Math.floor(totalDuration / 3600); const minutes = Math.floor((totalDuration % 3600) / 60); document.getElementById('workout-duration').innerText = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; // Format distance const km = (totalDistance / 1000).toFixed(1); document.getElementById('workout-distance').innerText = `${km} km`; // Format calories document.getElementById('workout-calories').innerText = Math.round(totalCalories).toLocaleString(); // Charts renderWorkoutTypesChart(summary.by_activity_type); renderWorkoutWeeklyChart(workouts); // List renderWorkoutsList(workouts); } function renderWorkoutTypesChart(data) { const ctx = document.getElementById('chart-workout-types'); if (!ctx) return; if (workoutState.charts.types) { workoutState.charts.types.destroy(); } const colors = [ '#f97316', // orange '#3b82f6', // blue '#10b981', // green '#8b5cf6', // purple '#ec4899', // pink '#14b8a6', // teal '#f59e0b', // amber '#6366f1' // indigo ]; workoutState.charts.types = new Chart(ctx, { type: 'doughnut', data: { labels: data.map(d => d.activity_type), datasets: [{ data: data.map(d => d.count), backgroundColor: colors.slice(0, data.length), borderWidth: 0, hoverOffset: 10 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '65%', plugins: { legend: { position: 'bottom', labels: { padding: 15, usePointStyle: true, color: document.documentElement.classList.contains('dark') ? '#d1d5db' : '#374151' } } } } }); } function renderWorkoutWeeklyChart(workouts) { const ctx = document.getElementById('chart-workout-weekly'); if (!ctx) return; if (workoutState.charts.weekly) { workoutState.charts.weekly.destroy(); } // Group workouts by week const weeklyData = {}; const now = new Date(); // Initialize last 12 weeks for (let i = 11; i >= 0; i--) { const weekStart = new Date(now); weekStart.setDate(now.getDate() - (i * 7)); const weekKey = weekStart.toISOString().split('T')[0].slice(0, 7) + '-W' + getWeekNumber(weekStart); weeklyData[weekKey] = { count: 0, duration: 0, calories: 0 }; } workouts.forEach(w => { const date = new Date(w.start_time); const weekKey = date.toISOString().split('T')[0].slice(0, 7) + '-W' + getWeekNumber(date); if (weeklyData[weekKey]) { weeklyData[weekKey].count++; weeklyData[weekKey].duration += (w.duration_seconds || 0) / 60; weeklyData[weekKey].calories += w.calories || 0; } }); const labels = Object.keys(weeklyData).map(k => k.split('-W')[1] ? 'W' + k.split('-W')[1] : k); const isDark = document.documentElement.classList.contains('dark'); workoutState.charts.weekly = new Chart(ctx, { type: 'bar', data: { labels: labels.slice(-8), datasets: [{ label: 'Workouts', data: Object.values(weeklyData).slice(-8).map(d => d.count), backgroundColor: '#f97316', borderRadius: 6 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: isDark ? '#374151' : '#e5e7eb' }, ticks: { stepSize: 1 } }, x: { grid: { display: false } } } } }); } function getWeekNumber(date) { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); } function renderWorkoutsList(workouts, filterType = '') { const container = document.getElementById('workouts-list'); if (!container) return; let filtered = workouts; if (filterType) { filtered = workouts.filter(w => w.activity_type === filterType); } if (filtered.length === 0) { container.innerHTML = `

No workouts found

`; return; } container.innerHTML = filtered.slice(0, 50).map(w => { const date = new Date(w.start_time); const duration = formatDuration(w.duration_seconds); const distance = (w.distance_meters / 1000).toFixed(2); const icon = getActivityIcon(w.activity_type); const color = getActivityColor(w.activity_type); return `

${w.activity_type}

${date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
${duration} ${w.distance_meters > 0 ? `${distance} km` : ''} ${Math.round(w.calories)} kcal
${date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
`; }).join(''); } function formatDuration(seconds) { if (!seconds) return '0m'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); if (h > 0) return `${h}h ${m}m`; return `${m}m`; } function getActivityIcon(type) { const icons = { 'Running': 'fas fa-person-running', 'Walking': 'fas fa-person-walking', 'Cycling': 'fas fa-bicycle', 'Elliptical': 'fas fa-heart-pulse', 'Free Training': 'fas fa-dumbbell', 'HIIT': 'fas fa-bolt', 'Stretching': 'fas fa-child-reaching', 'Yoga': 'fas fa-spa' }; return icons[type] || 'fas fa-dumbbell'; } function getActivityColor(type) { const colors = { 'Running': 'bg-gradient-to-br from-orange-500 to-red-500', 'Walking': 'bg-gradient-to-br from-green-500 to-emerald-500', 'Cycling': 'bg-gradient-to-br from-blue-500 to-cyan-500', 'Elliptical': 'bg-gradient-to-br from-purple-500 to-pink-500', 'Free Training': 'bg-gradient-to-br from-gray-600 to-gray-700', 'HIIT': 'bg-gradient-to-br from-red-500 to-rose-600', 'Stretching': 'bg-gradient-to-br from-teal-500 to-cyan-500', 'Yoga': 'bg-gradient-to-br from-indigo-500 to-purple-500' }; return colors[type] || 'bg-gradient-to-br from-gray-500 to-gray-600'; } // --- Period-Specific Workout Renderers --- function renderWorkoutsDay() { const workouts = workoutState.workouts || []; const targetDate = state.currentDate || new Date(); const today = targetDate.toISOString().split('T')[0]; const todayWorkouts = workouts.filter(w => { const wDate = new Date(w.start_time).toISOString().split('T')[0]; return wDate === today; }); // Update timeline const timeline = document.getElementById('workout-timeline'); if (timeline) { if (todayWorkouts.length === 0) { timeline.innerHTML = `

No workouts recorded today

`; } else { timeline.innerHTML = todayWorkouts.map(w => { const date = new Date(w.start_time); const duration = formatDuration(w.duration_seconds); const distance = (w.distance_meters / 1000).toFixed(2); const icon = getActivityIcon(w.activity_type); const color = getActivityColor(w.activity_type); return `

${w.activity_type}

${date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}

${duration}
${w.distance_meters > 0 ? `${distance} km` : ''} ${Math.round(w.calories)} kcal
`; }).join(''); } } // Update summary cards const totalDuration = todayWorkouts.reduce((sum, w) => sum + (w.duration_seconds || 0), 0); const totalDistance = todayWorkouts.reduce((sum, w) => sum + (w.distance_meters || 0), 0); const totalCalories = todayWorkouts.reduce((sum, w) => sum + (w.calories || 0), 0); const countEl = document.getElementById('day-workout-count'); const durationEl = document.getElementById('day-workout-duration'); const distanceEl = document.getElementById('day-workout-distance'); const caloriesEl = document.getElementById('day-workout-calories'); if (countEl) countEl.innerText = todayWorkouts.length; if (durationEl) durationEl.innerText = formatDuration(totalDuration); if (distanceEl) distanceEl.innerText = `${(totalDistance / 1000).toFixed(1)} km`; if (caloriesEl) caloriesEl.innerText = Math.round(totalCalories); } function renderWorkoutsWeek() { // Week view is already rendered by renderWorkoutsTab // Just ensure the data is visible const summary = workoutState.summary; if (!summary) return; // Render intensity distribution (mock data for now) const lowEl = document.getElementById('week-low-intensity'); const modEl = document.getElementById('week-mod-intensity'); const highEl = document.getElementById('week-high-intensity'); if (lowEl) lowEl.innerText = '45%'; if (modEl) modEl.innerText = '35%'; if (highEl) highEl.innerText = '20%'; } function renderWorkoutsMonth() { const workouts = workoutState.workouts || []; const targetDate = state.currentDate || new Date(); const firstDayOfMonth = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1); const lastDayOfMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0); // Filter workouts for current month const monthWorkouts = workouts.filter(w => { const wDate = new Date(w.start_time); return wDate >= firstDayOfMonth && wDate <= lastDayOfMonth; }); // Calculate monthly totals const totalSessions = monthWorkouts.length; const totalDuration = monthWorkouts.reduce((sum, w) => sum + (w.duration_seconds || 0), 0); const totalDistance = monthWorkouts.reduce((sum, w) => sum + (w.distance_meters || 0), 0); const totalCalories = monthWorkouts.reduce((sum, w) => sum + (w.calories || 0), 0); // Update summary cards const sessionsEl = document.getElementById('month-total-sessions'); const distanceEl = document.getElementById('month-total-distance'); const caloriesEl = document.getElementById('month-total-calories'); const volumeEl = document.getElementById('month-total-volume'); if (sessionsEl) sessionsEl.innerText = totalSessions; if (distanceEl) distanceEl.innerText = `${(totalDistance / 1000).toFixed(1)} km`; if (caloriesEl) caloriesEl.innerText = Math.round(totalCalories); if (volumeEl) volumeEl.innerText = `${(totalDuration / 3600).toFixed(1)} hrs`; // Render cumulative volume chart renderCumulativeVolumeChart(monthWorkouts); // Render activity calendar renderWorkoutCalendar(monthWorkouts); } function renderCumulativeVolumeChart(monthWorkouts) { const ctx = document.getElementById('chart-cumulative-volume'); if (!ctx) return; // Destroy existing chart if (workoutState.charts.cumulative) { workoutState.charts.cumulative.destroy(); } const targetDate = state.currentDate || new Date(); const daysInMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate(); // Create cumulative data const cumulativeData = []; let cumulative = 0; for (let day = 1; day <= daysInMonth; day++) { const dayDate = new Date(targetDate.getFullYear(), targetDate.getMonth(), day); const dayStr = dayDate.toISOString().split('T')[0]; const dayWorkouts = monthWorkouts.filter(w => { return new Date(w.start_time).toISOString().split('T')[0] === dayStr; }); const dayDuration = dayWorkouts.reduce((sum, w) => sum + (w.duration_seconds || 0), 0); cumulative += dayDuration / 3600; // Convert to hours cumulativeData.push(cumulative); } const labels = Array.from({ length: daysInMonth }, (_, i) => i + 1); const isDark = document.documentElement.classList.contains('dark'); workoutState.charts.cumulative = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [{ label: 'This Month', data: cumulativeData, borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', fill: true, tension: 0.4, borderWidth: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: isDark ? '#374151' : '#e5e7eb' }, ticks: { color: isDark ? '#d1d5db' : '#374151', callback: (value) => value + 'h' } }, x: { grid: { display: false }, ticks: { color: isDark ? '#d1d5db' : '#374151' } } } } }); } function renderWorkoutCalendar(monthWorkouts) { const calendar = document.getElementById('workout-calendar'); if (!calendar) return; const targetDate = state.currentDate || new Date(); const today = new Date(); const firstDay = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1); const lastDay = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0); const daysInMonth = lastDay.getDate(); const startingDayOfWeek = firstDay.getDay(); // Create calendar grid let html = ''; // Day headers const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; dayNames.forEach(day => { html += `
${day}
`; }); // Empty cells before first day for (let i = 0; i < startingDayOfWeek; i++) { html += `
`; } // Days of month for (let day = 1; day <= daysInMonth; day++) { const dayDate = new Date(targetDate.getFullYear(), targetDate.getMonth(), day); const dayStr = dayDate.toISOString().split('T')[0]; const dayWorkouts = monthWorkouts.filter(w => { return new Date(w.start_time).toISOString().split('T')[0] === dayStr; }); const hasWorkout = dayWorkouts.length > 0; const isToday = dayDate.toDateString() === today.toDateString(); let cellClass = 'p-2 text-center rounded-lg text-sm '; if (isToday) { cellClass += 'ring-2 ring-brand-500 '; } if (hasWorkout) { cellClass += 'bg-green-500 text-white font-bold'; } else { cellClass += 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'; } html += `
${day}
`; } calendar.innerHTML = html; }