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); }; window.toggleTagsInput = () => { const container = document.getElementById('tags-input-container'); if (container) container.classList.toggle('hidden'); }; async function saveContext() { const slider = document.getElementById('readiness-slider'); const tagsInput = document.getElementById('context-tags-input'); const dateStr = state.currentDate.toISOString().split('T')[0]; const payload = { date: dateStr, mood: parseInt(slider.value), tags: tagsInput.value, notes: null }; try { const res = await fetch(`${API_URL}/analytics/context`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { // Toast or feedback const btn = document.getElementById('save-context-btn'); const originalText = btn.innerText; btn.innerText = 'Saved!'; btn.classList.add('bg-green-600'); setTimeout(() => { btn.innerText = originalText; btn.classList.remove('bg-green-600'); }, 2000); // Reload data to refresh readiness with new context loadHealthData(state.healthPeriod); } } catch (e) { console.error("Failed to save context", e); } } // Bind save button (delegate or init) document.addEventListener('click', (e) => { if (e.target && e.target.id === 'save-context-btn') { saveContext(); } }); 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 --- // Google Fit & Health Dashboard Legacy Code Removed 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'); // Sync buttons removed 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(); switchPeriodViews(state.healthPeriod); 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); } }); }); // Google Fit Sync Button Removed // Health Connect Sync Button Removed // Google Token Check Removed // Init Sources // Sync timeframe buttons with initial state timeframeBtns.forEach(btn => { if (btn.dataset.period === state.healthPeriod) { 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'); } else { 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'); } }); fetchDataSources(); } async function fetchDataSources() { try { const res = await fetch(`${API_URL}/analytics/sources`); if (!res.ok) return; state.availableSources = await res.json(); const selector = document.getElementById('health-source-select'); const container = document.getElementById('source-selector-container'); const labelContainer = document.getElementById('source-label-container'); const nameDisplay = document.getElementById('source-name-display'); // Reset selector.innerHTML = ''; if (state.availableSources.length === 0) { container.classList.add('hidden'); labelContainer.classList.add('hidden'); } else if (state.availableSources.length === 1) { // One source: Show Label const src = state.availableSources[0]; state.currentSource = src.package; // Auto-select nameDisplay.textContent = src.name; container.classList.add('hidden'); labelContainer.classList.remove('hidden'); } else { // Multiple: Show Selector state.availableSources.forEach(src => { const opt = document.createElement('option'); opt.value = src.package; opt.textContent = src.name; selector.appendChild(opt); }); container.classList.remove('hidden'); labelContainer.classList.add('hidden'); // Listener selector.addEventListener('change', (e) => { state.currentSource = e.target.value; loadHealthData(state.healthPeriod); }); } } catch (e) { console.error("Failed to load sources", e); } } // performSync removed async function loadHealthData(period) { const dateStr = state.currentDate.toISOString().split('T')[0]; const key = `${period}_${dateStr}_${state.currentSource || 'all'}`; try { let data; let mappedData = []; const sourceParam = state.currentSource ? `&source=${state.currentSource}` : ''; if (period === 'day') { // Fetch single day const res = await fetch(`${API_URL}/analytics/daily?date_str=${dateStr}${sourceParam}`); if (!res.ok) throw new Error("Failed to fetch daily stats"); const dayStats = await res.json(); // Map to expected format (array of 1) mappedData = [{ date: dateStr, step_count: dayStats.steps, calories_burned: dayStats.calories, sleep_duration_minutes: dayStats.sleep_minutes, pai_score: (dayStats.steps / 100), // Mock PAI from steps // Sleep Stages deep_sleep_minutes: dayStats.deep_sleep_minutes, light_sleep_minutes: dayStats.light_sleep_minutes, rem_sleep_minutes: dayStats.rem_sleep_minutes, awake_duration_minutes: dayStats.awake_duration_minutes, // Heart Rate resting_heart_rate: dayStats.resting_hr, avg_heart_rate: dayStats.avg_heart_rate, hrv: dayStats.hrv, sleep_heart_rate: dayStats.sleep_avg_hr, min_heart_rate: dayStats.min_heart_rate, max_heart_rate: dayStats.max_heart_rate, // Other weight_kg: dayStats.weight_kg, distance_meters: dayStats.distance_meters, bedtime: dayStats.bedtime, wake_time: dayStats.wake_time, // Readiness & Context readiness: dayStats.readiness, user_context: dayStats.user_context }]; // Day View specific cards are now handled by renderHealthDashboard via mappedData } else { // Fetch range let start, end; const d = new Date(state.currentDate); if (period === 'week') { end = new Date(d); start = new Date(d); start.setDate(start.getDate() - 6); } else { end = new Date(d); start = new Date(d); start.setDate(start.getDate() - 29); } const sStr = start.toISOString().split('T')[0]; const eStr = end.toISOString().split('T')[0]; const res = await fetch(`${API_URL}/analytics/range?start_date=${sStr}&end_date=${eStr}${sourceParam}`); if (!res.ok) throw new Error("Failed to fetch range stats"); const rangeData = await res.json(); // Map range data mappedData = rangeData.daily.map(day => ({ date: day.date, step_count: day.steps, calories_burned: day.calories, // Range endpoint needs to return this, updated analytics.py sleep_duration_minutes: day.sleep_minutes, pai_score: (day.steps / 100), distance_meters: day.distance_meters, // Add defaults for missing detailed stats in range view deep_sleep_minutes: 0, light_sleep_minutes: 0, rem_sleep_minutes: 0, awake_duration_minutes: 0, resting_heart_rate: day.resting_hr || 0, hrv: day.hrv || 0, avg_heart_rate: 0, min_heart_rate: 0, max_heart_rate: 0, weight_kg: 0, weight_kg: 0, bedtime: day.bedtime, wake_time: day.wake_time, // Enriched Range Data readiness: { score: day.readiness, physical_score: 0, mental_score: day.recovery_score }, // Simplified mapping strain: day.strain, recovery_score: day.recovery_score, mood_score: day.mood_score })); // Inside else block, we have sStr and eStr await loadWorkoutsData(sStr, eStr); } // Load workouts for Day period here (Range handled above) if (period === 'day') { await loadWorkoutsData(dateStr, dateStr); } renderHealthDashboard(mappedData, period); renderReadinessView(mappedData, period); } catch (err) { console.error("Error loading health data", err); showToast("Failed to load health metrics", "error"); } } // --- NEW READINESS VIEW RENDERING --- function renderReadinessView(data, period) { if (!data || data.length === 0) return; // --- DAY VIEW --- if (period === 'day') { const today = data[0]; const readiness = today.readiness || { score: 0, mental_score: 0, physical_score: 0, recommendation: "No data" }; const context = today.user_context || { mood_score: 50, tags: [] }; // 1. Update Gauge (Split) drawSplitGauge( 'gauge-readiness-split', readiness.physical_score || 0, readiness.mental_score || 0 ); // 2. Update Strings const scoreEl = document.getElementById('readiness-score'); if (scoreEl) scoreEl.innerText = readiness.score || '--'; const textEl = document.getElementById('readiness-text'); if (textEl) textEl.innerHTML = readiness.recommendation || 'No data available.'; // 3. Update Inputs const slider = document.getElementById('readiness-slider'); const display = document.getElementById('slider-val-display'); const tagsInput = document.getElementById('context-tags-input'); if (slider) { slider.value = context.mood_score || 50; if (display) display.innerText = slider.value + '%'; // Local listener for display update slider.oninput = (e) => { if (display) display.innerText = e.target.value + '%'; }; } if (tagsInput && context.tags) { tagsInput.value = context.tags.join(', '); } } // --- WEEK VIEW --- else if (period === 'week') { const labels = data.map(d => new Date(d.date).toLocaleDateString('en-US', { weekday: 'short' })); const strainData = data.map(d => d.strain || 0); const recoveryData = data.map(d => d.recovery_score || 0); const hrvData = data.map(d => d.hrv || 0); const ctx = document.getElementById('chart-recovery-strain').getContext('2d'); if (state.charts.readinessWeek) state.charts.readinessWeek.destroy(); const isDark = document.documentElement.classList.contains('dark'); const textColor = isDark ? '#9ca3af' : '#4b5563'; state.charts.readinessWeek = new Chart(ctx, { type: 'bar', data: { labels: labels, datasets: [ { label: 'Strain', data: strainData, backgroundColor: '#ef4444', barPercentage: 0.6, categoryPercentage: 0.8 }, { label: 'Recovery', data: recoveryData, backgroundColor: '#22c55e', barPercentage: 0.6, categoryPercentage: 0.8 }, { label: 'HRV Trend', data: hrvData, type: 'line', borderColor: '#8b5cf6', borderWidth: 2, tension: 0.4, yAxisID: 'y1', pointRadius: 0 } ] }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { ticks: { color: textColor }, grid: { display: false } }, y: { beginAtZero: true, max: 100, ticks: { color: textColor }, grid: { color: isDark ? '#374151' : '#e5e7eb' }, title: { display: true, text: 'Score' } }, y1: { type: 'linear', position: 'right', display: false // Hide right axis scale to keep it clean, just show trend } }, plugins: { legend: { display: false } } } }); // Update Stats const avgRecovery = Math.round(recoveryData.reduce((a, b) => a + b, 0) / recoveryData.length) || 0; const avgStrain = Math.round(strainData.reduce((a, b) => a + b, 0) / strainData.length) || 0; const recEl = document.getElementById('week-avg-recovery'); const strEl = document.getElementById('week-avg-strain'); if (recEl) recEl.innerText = avgRecovery; if (strEl) strEl.innerText = avgStrain; // Simple Balance Logic const diff = avgRecovery - avgStrain; const balanceEl = document.getElementById('week-balance-status'); if (balanceEl) { if (diff < -20) { balanceEl.innerText = "Overload"; balanceEl.classList.add('text-red-500'); } else if (diff > 20) { balanceEl.innerText = "Undertraining"; balanceEl.classList.add('text-blue-500'); } else { balanceEl.innerText = "Optimal"; balanceEl.classList.add('text-green-500'); } } } // --- MONTH VIEW --- else if (period === 'month') { const heatmapContainer = document.getElementById('heatmap-container'); if (heatmapContainer) { heatmapContainer.innerHTML = ''; // Heatmap of Readiness data.forEach(d => { const dayDiv = document.createElement('div'); const readiness = d.recovery_score || d.readiness?.score || 0; let colorClass = 'bg-gray-200 dark:bg-gray-700'; // Color Scale if (readiness > 85) colorClass = 'bg-green-500'; else if (readiness > 70) colorClass = 'bg-green-400'; else if (readiness > 50) colorClass = 'bg-yellow-400'; else if (readiness > 30) colorClass = 'bg-orange-400'; else if (readiness > 0) colorClass = 'bg-red-500'; dayDiv.className = `w-full h-8 rounded-sm ${colorClass} transition-opacity hover:opacity-80`; dayDiv.title = `${d.date}: ${readiness}`; heatmapContainer.appendChild(dayDiv); }); } // Baseline Graph const ctxHrv = document.getElementById('chart-hrv-trend').getContext('2d'); if (state.charts.readinessMonth) state.charts.readinessMonth.destroy(); const labels = data.map(d => d.date.split('-')[2]); // Just day const hrvData = data.map(d => d.hrv || 0); const isDark = document.documentElement.classList.contains('dark'); const textColor = isDark ? '#9ca3af' : '#4b5563'; state.charts.readinessMonth = new Chart(ctxHrv, { type: 'line', data: { labels: labels, datasets: [{ label: 'Daily HRV', data: hrvData, borderColor: '#8b5cf6', backgroundColor: '#8b5cf610', fill: true, tension: 0.4, pointRadius: 2 }] }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { ticks: { color: textColor, maxTicksLimit: 10 }, grid: { display: false } }, y: { ticks: { color: textColor }, grid: { color: isDark ? '#374151' : '#e5e7eb' } } }, plugins: { legend: { display: false } } } }); // Calculate Month Stats const avgHrv = Math.round(hrvData.reduce((a, b) => a + b, 0) / hrvData.length) || 0; const hrvAvgEl = document.getElementById('month-hrv-avg'); if (hrvAvgEl) hrvAvgEl.innerText = avgHrv + ' ms'; } } function drawSplitGauge(canvasId, physical, mental) { const canvas = document.getElementById(canvasId); if (!canvas) return; const ctx = canvas.getContext('2d'); // Resize handling using clientWidth/Height for HDPI const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); const w = rect.width; const h = rect.height; if (w === 0 || h === 0) return; // Canvas not ready or hidden const cx = w / 2; const cy = h / 2; const r = Math.min(w, h) / 2 - 20; const lineWidth = 15; if (r <= 5) return; // Too small to draw meaningfully ctx.clearRect(0, 0, w, h); ctx.lineCap = 'round'; // Helper to draw arc function drawArc(startAngle, endAngle, color, radius) { if (radius <= 0) return; ctx.beginPath(); ctx.arc(cx, cy, radius, startAngle, endAngle); ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.stroke(); } // Background Rings drawArc(Math.PI * 0.8, Math.PI * 2.2, '#e5e7eb', r); // Outer bg (Physical) drawArc(Math.PI * 0.8, Math.PI * 2.2, '#e5e7eb', r - 20); // Inner bg (Mental) // Physical (Outer) // Map 0-100 to angle range const start = Math.PI * 0.8; const totalAngle = Math.PI * 1.4; // 2.2 - 0.8 const physEnd = start + (totalAngle * (physical / 100)); // Color logic const physColor = physical > 80 ? '#22c55e' : (physical > 50 ? '#eab308' : '#ef4444'); drawArc(start, physEnd, physColor, r); // Mental (Inner) const mentEnd = start + (totalAngle * (mental / 100)); const mentColor = mental > 80 ? '#3b82f6' : (mental > 50 ? '#a855f7' : '#ef4444'); drawArc(start, mentEnd, mentColor, r - 20); } 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-deep-overview', '--'); safeSetText('val-rem-overview', '--'); safeSetText('val-light-overview', '--'); ['sleep-bar-deep', 'sleep-bar-rem', 'sleep-bar-light'].forEach(id => { const el = document.getElementById(id); if (el) el.style.width = '0%'; }); 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); // Sleep Stages const deepMin = latest.deep_sleep_minutes || 0; const remMin = latest.rem_sleep_minutes || 0; const lightMin = latest.light_sleep_minutes || 0; const fmtTime = (min) => { const h = Math.floor(min / 60); const m = min % 60; return h > 0 ? `${h}h ${m}m` : `${m}m`; }; const safeSetText = (id, val) => { const el = document.getElementById(id); if (el) el.innerText = val; }; safeSetText('val-deep-overview', fmtTime(deepMin)); safeSetText('val-rem-overview', fmtTime(remMin)); safeSetText('val-light-overview', fmtTime(lightMin)); // Stacked Bar (Goal: 8 hours) const sleepGoal = 8 * 60; const deepPct = Math.min(100, (deepMin / sleepGoal) * 100); const remPct = Math.min(100, (remMin / sleepGoal) * 100); const lightPct = Math.min(100, (lightMin / sleepGoal) * 100); const setWidth = (id, pct) => { const el = document.getElementById(id); if (el) el.style.width = `${pct}%`; }; setWidth('sleep-bar-deep', deepPct); setWidth('sleep-bar-rem', remPct); setWidth('sleep-bar-light', lightPct); // 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 --- updateReadinessUI(latest, data); 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.sleep_heart_rate || '--'; // Sleep Quality & Efficiency const sleepScore = calculateSleepScore(latest); const scoreEl = document.getElementById('sleep-quality-score'); if (scoreEl) scoreEl.innerText = sleepScore; const efficiency = total + awake > 0 ? Math.round((total / (total + awake)) * 100) : '--'; const cellEff = document.getElementById('sleep-efficiency'); if (cellEff) cellEff.innerText = `${efficiency}%`; // Bedtime / Wake if (latest.bedtime) { const bt = new Date(latest.bedtime); document.getElementById('sleep-start-time').innerText = `Bedtime: ${bt.getHours()}:${bt.getMinutes().toString().padStart(2, '0')}`; } else { document.getElementById('sleep-start-time').innerText = `Bedtime: --:--`; } if (latest.wake_time) { const wt = new Date(latest.wake_time); document.getElementById('sleep-end-time').innerText = `Wake: ${wt.getHours()}:${wt.getMinutes().toString().padStart(2, '0')}`; } else { document.getElementById('sleep-end-time').innerText = `Wake: --:--`; } 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, bedtime: d.bedtime, wake_time: d.wake_time })); if (period === 'week' && dailyData.length > 0) { renderWeekOverview({ daily: dailyData }); renderSleepWeek(dailyData); } else if (period === 'month' && dailyData.length > 0) { renderMonthOverview({ daily: dailyData }); renderSleepMonth(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`; } // --- Tab: Workouts Update --- if (period === 'month') { renderWorkoutsMonth(); } else if (period === 'week') { renderWorkoutsWeek(); } else { renderWorkoutsDay(); } } function calculateSleepScore(data) { if (!data.sleep_duration_minutes) return '--'; let score = 50; // Base score // Duration (Goal: 7-9 hours) const hours = data.sleep_duration_minutes / 60; if (hours >= 7 && hours <= 9) score += 25; else if (hours >= 6) score += 15; else if (hours > 4) score += 5; // Deep Sleep (Goal: ~20% of total) const deepPct = (data.deep_sleep_minutes / data.sleep_duration_minutes) * 100; if (deepPct >= 15 && deepPct <= 25) score += 15; else if (deepPct > 10) score += 10; // Consistency (Time of bed) if (data.bedtime) { const bt = new Date(data.bedtime); const hour = bt.getHours(); // Bedtime between 10pm and 12am is usually "better" for circadian rhythm if (hour >= 21 || hour <= 0) score += 10; } // Awake time penalty if (data.awake_duration_minutes > 60) score -= 10; else if (data.awake_duration_minutes > 30) score -= 5; return Math.min(100, Math.max(0, Math.round(score))); } // --- Readiness Logic --- function calculateReadiness(data) { if (!data) return { score: 0, factors: [] }; let score = 70; // Base score const factors = []; // 1. Sleep Factor (Max +/- 20) // Goal: 7-9 hours const sleepHrs = (data.sleep_duration_minutes || 0) / 60; if (sleepHrs >= 7) { score += 15; factors.push({ label: 'Good sleep duration (7h+)', type: 'good' }); } else if (sleepHrs >= 6) { score += 5; factors.push({ label: 'Moderate sleep duration', type: 'neutral' }); } else { score -= 15; factors.push({ label: 'Poor sleep duration (<6h)', type: 'bad' }); } // 2. Resting HR Factor (Max +/- 15) // Baseline assumption: 60bpm. Lower is better. const rhr = data.resting_heart_rate; if (rhr) { if (rhr <= 60) { score += 15; factors.push({ label: 'Optimal resting heart rate', type: 'good' }); } else if (rhr <= 70) { // Neutral } else { score -= 10; factors.push({ label: 'Elevated resting heart rate', type: 'bad' }); } } // 3. HRV Factor (Max +/- 15) // Baseline assumption: 40ms. Higher is better. const hrv = data.hrv; if (hrv) { if (hrv >= 50) { score += 15; factors.push({ label: 'High HRV (Recovery)', type: 'good' }); } else if (hrv >= 30) { // Neutral } else { score -= 10; factors.push({ label: 'Low HRV (Stress)', type: 'bad' }); } } else { // No HRV data - neutral factors.push({ label: 'No HRV data available', type: 'neutral' }); } return { score: Math.min(100, Math.max(0, score)), factors }; } function updateReadinessUI(latest, allData) { const { score, factors } = calculateReadiness(latest); // 1. Update Score & Ring document.getElementById('readiness-score').innerText = score; const ring = document.getElementById('readiness-ring'); if (ring) { // Circumference approx 440 const offset = 440 - (440 * (score / 100)); ring.style.strokeDashoffset = offset; // Color based on score ring.classList.remove('text-green-500', 'text-yellow-500', 'text-orange-500', 'text-red-500'); if (score >= 85) ring.classList.add('text-green-500'); else if (score >= 70) ring.classList.add('text-green-400'); else if (score >= 50) ring.classList.add('text-yellow-500'); else ring.classList.add('text-red-500'); } // 2. Update Text const textEl = document.getElementById('readiness-text'); if (textEl) { if (score >= 85) { textEl.innerHTML = `Your physical recovery is excellent. You are ready for high intensity training.`; } else if (score >= 70) { textEl.innerHTML = `You are well recovered. Ready for normal training load.`; } else if (score >= 50) { textEl.innerHTML = `Recovery is moderate. Consider a lighter training session.`; } else { textEl.innerHTML = `Your body needs rest. Focus on active recovery today.`; } } // 3. Update Factors List const factorsContainer = document.getElementById('readiness-factors'); if (factorsContainer) { factorsContainer.innerHTML = factors.map(f => { let icon, color, bg; if (f.type === 'good') { icon = 'fa-check-circle text-green-500'; bg = 'bg-green-50 dark:bg-green-900/20'; } else if (f.type === 'bad') { icon = 'fa-exclamation-circle text-red-500'; bg = 'bg-red-50 dark:bg-red-900/20'; } else { icon = 'fa-minus-circle text-yellow-500'; bg = 'bg-yellow-50 dark:bg-yellow-900/20'; } return `
${f.label}
`; }).join(''); } // 4. Update Metrics (RHR/HRV) const rhrVal = latest.resting_heart_rate || '--'; const hrvVal = latest.hrv ? Math.round(latest.hrv) : '--'; document.getElementById('val-rhr').innerText = rhrVal; document.getElementById('val-hrv').innerText = hrvVal; } // --- 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', referenceDate: null, // Will be initialized on open 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 // Open metric details modal window.openMetricDetails = function (metric) { metricState.currentMetric = metric; metricState.currentPeriod = 'recent'; metricState.referenceDate = new Date(); 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 view to chart document.getElementById('metric-chart-view').classList.remove('hidden'); document.getElementById('metric-list-view').classList.add('hidden'); const historyBtn = document.getElementById('history-data-btn'); if (historyBtn) { historyBtn.innerHTML = 'History Data'; } // 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 refDate = metricState.referenceDate || state.currentDate || new Date(); const dateStr = refDate.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' : ''); // TRENDS for Weight if (metric === 'weight') { const earliest = values[0]; const latestVal = values[values.length - 1]; const change = latestVal - earliest; const trendProgressEl = document.getElementById('trend-progress'); if (trendProgressEl) { const icon = change > 0 ? '' : ''; const color = change > 0 ? 'text-red-500' : 'text-green-500'; const sign = change > 0 ? '+' : ''; trendProgressEl.parentElement.className = color; trendProgressEl.innerHTML = `${icon} ${sign}${change.toFixed(1)} lbs`; trendProgressEl.previousElementSibling.outerHTML = icon; // Update icon } const trendCompEl = document.getElementById('trend-comparison'); if (trendCompEl) { const diffAvg = latestVal - parseFloat(avg); const icon = diffAvg > 0 ? '' : ''; const color = diffAvg > 0 ? 'text-red-500' : 'text-green-500'; const sign = diffAvg > 0 ? '+' : ''; trendCompEl.parentElement.className = color; trendCompEl.innerHTML = `vs Avg: ${sign}${diffAvg.toFixed(1)} lbs`; // trendCompEl.previousElementSibling.outerHTML = icon; } // Ideal Weight (BMI 22) // heightM = 1.75 default const idealKg = 22 * (1.75 * 1.75); const idealLbs = idealKg * 2.20462; const idealEl = document.getElementById('ideal-weight'); if (idealEl) idealEl.innerText = idealLbs.toFixed(1) + ' lbs'; } } // Render chart with full history renderMetricChart(processedData, metric); // Render table renderMetricTable(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 } } } } }); } function renderMetricTable(data, metric) { const tbody = document.getElementById('metric-history-table-body'); const countEl = document.getElementById('history-entry-count'); if (!tbody) return; tbody.innerHTML = ''; // Filter valid data and sort desc const validData = data.filter(d => metric === 'weight' ? (d.weight_norm || d.weight) : d.bmi) .sort((a, b) => new Date(b.date) - new Date(a.date)); if (countEl) countEl.innerText = `${validData.length} entries`; if (validData.length === 0) { tbody.innerHTML = 'No data available'; return; } validData.forEach((d, i) => { const val = metric === 'weight' ? (d.weight_norm || d.weight) * 2.20462 : d.bmi; const prevVal = (i < validData.length - 1) ? (metric === 'weight' ? (validData[i + 1].weight_norm || validData[i + 1].weight) * 2.20462 : validData[i + 1].bmi) : null; let changeHtml = '-'; if (prevVal) { const diff = val - prevVal; if (Math.abs(diff) < 0.1) { changeHtml = '-'; } else if (diff > 0) { changeHtml = ` +${diff.toFixed(1)}`; } else { changeHtml = ` ${diff.toFixed(1)}`; } // Invert colors for Weight if gaining is bad? Usually gaining weight is bad for weight loss apps. // But if metric is generic, leave it. Assuming weight loss goal: Red = Up, Green = Down. } const date = new Date(d.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); const tr = document.createElement('tr'); tr.className = 'hover:bg-gray-700/30 transition-colors'; tr.innerHTML = ` ${date} ${val.toFixed(1)} ${metric === 'weight' ? 'lbs' : ''} ${changeHtml} `; tbody.appendChild(tr); }); } // 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; metricState.referenceDate = new Date(); // Reset date on period change // 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); }); }); // Navigation Buttons const prevBtn = document.getElementById('metric-prev-btn'); const nextBtn = document.getElementById('metric-next-btn'); const changeTime = (direction) => { const d = new Date(metricState.referenceDate); const period = metricState.currentPeriod; if (period === 'recent' || period === 'week') { d.setDate(d.getDate() + (direction * 7)); } else if (period === 'month') { d.setMonth(d.getMonth() + direction); } else if (period === 'year') { d.setFullYear(d.getFullYear() + direction); } // Prevent future dates? Optional, but good practice usually. // if (d > new Date()) return; metricState.referenceDate = d; loadMetricHistory(metricState.currentMetric, period); }; if (prevBtn) prevBtn.addEventListener('click', () => changeTime(-1)); if (nextBtn) nextBtn.addEventListener('click', () => changeTime(1)); // History data button (Toggle View) const historyBtn = document.getElementById('history-data-btn'); if (historyBtn) { historyBtn.addEventListener('click', () => { const chartView = document.getElementById('metric-chart-view'); const listView = document.getElementById('metric-list-view'); if (chartView.classList.contains('hidden')) { // Show Chart chartView.classList.remove('hidden'); listView.classList.add('hidden'); historyBtn.innerHTML = 'History Data'; } else { // Show List chartView.classList.add('hidden'); listView.classList.remove('hidden'); historyBtn.innerHTML = 'Show Chart'; } }); } // 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 }; /** * 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 const avgPai = dailyData.length > 0 ? totalPai / dailyData.length : 0; document.getElementById('week-total-pai').innerText = Math.round(avgPai); 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 Sleep Week View */ function renderSleepWeek(dailyData) { if (!dailyData || dailyData.length === 0) return; // 1. Calculate Averages let totalDur = 0; let bedtimes = []; let waketimes = []; let bestDur = 0; let validDays = 0; dailyData.forEach(d => { if (d.sleep_duration > 0) { totalDur += d.sleep_duration; validDays++; if (d.sleep_duration > bestDur) bestDur = d.sleep_duration; } if (d.bedtime) { const bt = new Date(d.bedtime); let mins = bt.getHours() * 60 + bt.getMinutes(); if (mins > 12 * 60) mins -= 24 * 60; bedtimes.push(mins); } if (d.wake_time) { const wt = new Date(d.wake_time); waketimes.push(wt.getHours() * 60 + wt.getMinutes()); } }); const avgDur = validDays > 0 ? totalDur / validDays : 0; document.getElementById('week-avg-duration').innerText = `${Math.floor(avgDur / 60)}h ${Math.round(avgDur % 60)}m`; document.getElementById('week-best-night').innerText = `${Math.floor(bestDur / 60)}h ${Math.round(bestDur % 60)}m`; if (bedtimes.length > 0) { const avgBtMins = bedtimes.reduce((a, b) => a + b, 0) / bedtimes.length; const totalMins = avgBtMins + (avgBtMins < 0 ? 1440 : 0); const h = Math.floor(totalMins / 60) % 24; const m = Math.round(totalMins % 60); document.getElementById('week-avg-bedtime').innerText = `${h}:${m.toString().padStart(2, '0')}`; } if (waketimes.length > 0) { const avgWtMins = waketimes.reduce((a, b) => a + b, 0) / waketimes.length; const h = Math.floor(avgWtMins / 60); const m = Math.round(avgWtMins % 60); document.getElementById('week-avg-wake').innerText = `${h}:${m.toString().padStart(2, '0')}`; } document.getElementById('sleep-regularity-score').innerText = validDays > 4 ? 'High' : 'Moderate'; renderSleepScheduleChart(dailyData); } /** * Render Sleep Month View */ function renderSleepMonth(dailyData) { if (!dailyData || dailyData.length === 0) return; let totalDur = 0; let nightsBelow6 = 0; let validDays = 0; dailyData.forEach(d => { if (d.sleep_duration > 0) { totalDur += d.sleep_duration; validDays++; if (d.sleep_duration < 360) nightsBelow6++; } }); const avgDur = validDays > 0 ? totalDur / validDays : 0; document.getElementById('month-avg-sleep').innerText = `${Math.floor(avgDur / 60)}h ${Math.round(avgDur % 60)}m`; document.getElementById('month-nights-below').innerText = nightsBelow6; const goalMins = 8 * 60; const debtMins = (goalMins * validDays) - totalDur; const debtEl = document.getElementById('month-sleep-debt'); if (debtEl) { if (debtMins > 0) { debtEl.innerHTML = `-${Math.floor(debtMins / 60)}h ${Math.round(debtMins % 60)}m`; } else { debtEl.innerHTML = `+${Math.floor(Math.abs(debtMins) / 60)}h ${Math.round(Math.abs(debtMins) % 60)}m`; } } renderSleepDebtChart(dailyData); } function renderSleepScheduleChart(dailyData) { const ctx = document.getElementById('chart-sleep-schedule'); if (!ctx) return; if (periodCharts.sleepSchedule) periodCharts.sleepSchedule.destroy(); const formattedData = dailyData.map(d => { if (!d.bedtime || !d.wake_time) return null; const bt = new Date(d.bedtime); const wt = new Date(d.wake_time); let bVal = bt.getHours() + (bt.getMinutes() / 60); if (bVal >= 12) bVal -= 12; else bVal += 12; let wVal = wt.getHours() + (wt.getMinutes() / 60); if (wVal >= 12) wVal -= 12; else wVal += 12; return [bVal, wVal]; }); periodCharts.sleepSchedule = new Chart(ctx, { type: 'bar', data: { labels: dailyData.map(d => new Date(d.date).toLocaleDateString('en-US', { weekday: 'short' })), datasets: [{ label: 'Sleep Window', data: formattedData, backgroundColor: 'rgba(147, 51, 234, 0.6)', borderColor: '#9333ea', borderWidth: 1, borderRadius: 20, borderSkipped: false }] }, options: { responsive: true, maintainAspectRatio: false, indexAxis: 'x', scales: { y: { min: 6, max: 24, ticks: { stepSize: 2, callback: function (value) { let h = (value + 12) % 24; return (h === 0 ? 12 : (h > 12 ? h - 12 : h)) + (h >= 12 ? ' PM' : ' AM'); } } } }, plugins: { legend: { display: false } } } }); } function renderSleepDebtChart(dailyData) { const ctx = document.getElementById('chart-sleep-debt'); if (!ctx) return; if (periodCharts.sleepDebt) periodCharts.sleepDebt.destroy(); periodCharts.sleepDebt = new Chart(ctx, { type: 'bar', data: { labels: dailyData.map(d => d.date.split('-')[2]), datasets: [{ data: dailyData.map(d => d.sleep_duration / 60), backgroundColor: dailyData.map(d => { const h = d.sleep_duration / 60; if (h < 6) return '#ef4444'; if (h < 7) return '#f59e0b'; return '#10b981'; }), borderRadius: 4 }] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { max: 12, title: { display: true, text: 'Hours' } }, x: { ticks: { maxTicksLimit: 10 } } }, plugins: { legend: { display: false } } } }); } /** * 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(startDate, endDate) { try { let workoutsUrl = `${API_URL}/health/workouts?limit=200`; let summaryUrl = `${API_URL}/health/workouts/summary`; if (startDate && endDate) { workoutsUrl += `&start_date=${startDate}&end_date=${endDate}`; summaryUrl += `?start_date=${startDate}&end_date=${endDate}`; } // Load workouts list const workoutsRes = await fetch(workoutsUrl); if (workoutsRes.ok) { workoutState.workouts = await workoutsRes.json(); } // Load summary const summaryRes = await fetch(summaryUrl); if (summaryRes.ok) { workoutState.summary = await summaryRes.json(); } renderWorkoutsTab(); } catch (err) { console.error('Error loading workouts:', err); } } 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; }