Files
2026-07-30 23:25:20 -04:00

4335 lines
160 KiB
JavaScript

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 => `<i class="fas ${b.icon} ${b.color} mx-1" title="${b.title}"></i>`).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 = `
<div class="flex flex-col sm:flex-row h-full">
<!-- Left Side: Visual -->
<div class="relative w-full sm:w-64 sm:min-h-full h-32 flex-shrink-0 bg-gray-100 dark:bg-gray-900 group overflow-hidden">
${hasImage ? `
<img src="${challenge.image_url}" alt="${challenge.name}" class="w-full h-full object-cover object-center transition-transform duration-500 group-hover:scale-105">
` : `
<div class="w-full h-full flex items-center justify-center bg-brand-50 dark:bg-brand-900/20 text-brand-500 dark:text-brand-400">
<i class="fas ${iconClass} text-5xl opacity-50"></i>
</div>
`}
<!-- Floating Badges -->
<div class="absolute top-2 left-2 flex flex-col items-start gap-1 z-10">
<span class="text-[10px] font-bold uppercase tracking-widest bg-black/50 text-white px-2 py-0.5 rounded backdrop-blur-md shadow-sm border border-white/10">${challenge.period_type}</span>
${hasImage ? `
<span class="p-1.5 bg-black/50 backdrop-blur-md rounded-lg text-white border border-white/10">
<i class="fas ${iconClass}"></i>
</span>` : ''}
</div>
</div>
<!-- Right Side: Content -->
<div class="flex-1 p-4 flex flex-col justify-between min-w-0">
<div>
<div class="flex justify-between items-start mb-2 drag-handle cursor-move">
<div class="mr-4 min-w-0 flex-1">
<h3 class="text-lg font-bold text-gray-900 dark:text-white leading-tight truncate" title="${challenge.name}">${challenge.name}</h3>
${challenge.description ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2" title="${challenge.description}">${challenge.description}</p>` : ''}
</div>
<button class="delete-btn text-gray-400 hover:text-red-500 transition-colors flex-shrink-0" data-id="${challenge.id}">
<i class="fas fa-trash-alt"></i>
</button>
</div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-semibold text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/30 px-2 py-0.5 rounded border border-brand-100 dark:border-brand-800">Target: ${challenge.daily_target} ${challenge.unit_type || 'reps'}</span>
<div class="flex space-x-1">${badgesHtml}</div>
</div>
</div>
<!-- Progress & Controls -->
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-3 border border-gray-100 dark:border-gray-700/50">
<div class="flex justify-between items-center mb-1.5">
<span class="text-[10px] uppercase font-bold text-gray-500">Today</span>
<div class="flex items-center gap-1">
<span class="text-xs font-bold ${progressPercent >= 100 ? 'text-green-500' : 'text-gray-900 dark:text-white'}">${todayTracking.reps}</span>
<span class="text-[10px] text-gray-400">/ ${challenge.daily_target}</span>
</div>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-1.5 mb-3 overflow-hidden">
<div class="bg-brand-500 h-full rounded-full transition-all duration-500" style="width: ${progressPercent}%"></div>
</div>
<div class="flex justify-between items-center">
<div class="flex gap-1">
<button class="w-6 h-6 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 hover:border-brand-500 text-gray-400 hover:text-brand-500 transition-colors flex items-center justify-center active:scale-95"
onclick="adjustReps(${challenge.id}, '${today}', -1, ${todayTracking.reps})"><i class="fas fa-minus text-[10px]"></i></button>
<button class="w-6 h-6 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 hover:border-brand-500 text-gray-400 hover:text-brand-500 transition-colors flex items-center justify-center active:scale-95"
onclick="adjustReps(${challenge.id}, '${today}', 1, ${todayTracking.reps})"><i class="fas fa-plus text-[10px]"></i></button>
</div>
<div class="flex gap-1">
<button onclick="adjustReps(${challenge.id}, '${today}', 10, ${todayTracking.reps})" class="px-2 py-0.5 rounded text-[10px] font-bold bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors">+10</button>
<button onclick="adjustReps(${challenge.id}, '${today}', 25, ${todayTracking.reps})" class="px-2 py-0.5 rounded text-[10px] font-bold bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors">+25</button>
</div>
</div>
</div>
<!-- Footer: History -->
<div class="mt-3 flex items-center justify-end gap-1">
<span class="text-[9px] uppercase text-gray-400 mr-1">History</span>
${generateMiniHeatmap(challenge)}
</div>
</div>
</div>
`;
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 += `
<div class="group relative flex flex-col items-center cursor-help">
<div class="w-2.5 h-2.5 rounded-full ${colorClass}" title="${title}"></div>
</div>
`;
}
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 = `
<div class="w-10 h-10 rounded-full flex items-center justify-center mb-2 ${b.unlocked ? 'bg-brand-100 dark:bg-brand-800 text-brand-600 dark:text-brand-300' : 'bg-gray-200 dark:bg-gray-700 text-gray-400'}">
<i class="fas ${b.icon} text-lg"></i>
</div>
<span class="text-xs font-semibold text-center ${b.unlocked ? 'text-brand-700 dark:text-brand-300' : 'text-gray-500'}">${b.title}</span>
`;
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 = '<div class="p-2 text-sm text-gray-500">No images found</div>';
} 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 = `
<div class="h-10 w-10 flex-shrink-0 bg-gray-200 dark:bg-gray-600 rounded overflow-hidden">
<img src="${url}" class="h-full w-full object-cover">
</div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200 truncate">${img}</span>
`;
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 = '<option value="">Auto (Best)</option>';
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', `-- <span class="text-sm font-normal text-gray-400">bpm</span>`);
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 || '--'} <span class="text-sm font-normal text-gray-400">bpm</span>`;
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. <span class="text-gray-900 dark:text-white font-bold">You are ready for high intensity training.</span>`;
} else if (score >= 70) {
textEl.innerHTML = `You are well recovered. <span class="text-gray-900 dark:text-white font-bold">Ready for normal training load.</span>`;
} else if (score >= 50) {
textEl.innerHTML = `Recovery is moderate. <span class="text-gray-900 dark:text-white font-bold">Consider a lighter training session.</span>`;
} else {
textEl.innerHTML = `Your body needs rest. <span class="text-gray-900 dark:text-white font-bold">Focus on active recovery today.</span>`;
}
}
// 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 `
<div class="flex items-center gap-3 p-3 ${bg} rounded-lg">
<i class="fas ${icon}"></i>
<span class="text-gray-700 dark:text-gray-300">${f.label}</span>
</div>`;
}).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 = '<i class="fas fa-check"></i>';
} else if (type === 'error') {
colorClass = 'text-red-500 bg-red-100 dark:bg-red-800/30 dark:text-red-400';
iconHtml = '<i class="fas fa-exclamation-triangle"></i>';
} else {
colorClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800/30 dark:text-blue-400';
iconHtml = '<i class="fas fa-info-circle"></i>';
}
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 = `
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${colorClass}">
${iconHtml}
</div>
<div class="ml-3 text-sm font-medium flex-1">${message}</div>
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-transparent text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:hover:bg-gray-700">
<i class="fas fa-times"></i>
</button>
`;
// 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 = '<span class="font-medium">History Data</span><i class="fas fa-list text-gray-400"></i>';
}
// 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 ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
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 ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
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 = '<tr><td colspan="3" class="p-4 text-center text-gray-500">No data available</td></tr>';
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 = '<span class="text-gray-400">-</span>';
if (prevVal) {
const diff = val - prevVal;
if (Math.abs(diff) < 0.1) {
changeHtml = '<span class="text-gray-400">-</span>';
} else if (diff > 0) {
changeHtml = `<span class="text-red-500"><i class="fas fa-caret-up"></i> +${diff.toFixed(1)}</span>`;
} else {
changeHtml = `<span class="text-green-500"><i class="fas fa-caret-down"></i> ${diff.toFixed(1)}</span>`;
}
// 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 = `
<td class="p-4 text-gray-300">${date}</td>
<td class="p-4 text-right font-bold text-white">${val.toFixed(1)} ${metric === 'weight' ? 'lbs' : ''}</td>
<td class="p-4 text-right">${changeHtml}</td>
`;
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 = '<span class="font-medium">History Data</span><i class="fas fa-list text-gray-400"></i>';
} else {
// Show List
chartView.classList.add('hidden');
listView.classList.remove('hidden');
historyBtn.innerHTML = '<span class="font-medium">Show Chart</span><i class="fas fa-chart-line text-gray-400"></i>';
}
});
}
// 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 = `<span class="text-red-500">-${Math.floor(debtMins / 60)}h ${Math.round(debtMins % 60)}m</span>`;
} else {
debtEl.innerHTML = `<span class="text-green-500">+${Math.floor(Math.abs(debtMins) / 60)}h ${Math.round(Math.abs(debtMins) % 60)}m</span>`;
}
}
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 = `
<div class="p-8 text-center text-gray-400">
<i class="fas fa-dumbbell text-4xl mb-3 opacity-50"></i>
<p>No workouts found</p>
</div>
`;
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 `
<div class="p-4 flex items-center gap-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<div class="w-12 h-12 rounded-xl ${color} flex items-center justify-center text-white shadow-md">
<i class="${icon} text-lg"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h4 class="font-semibold text-gray-900 dark:text-white">${w.activity_type}</h4>
<span class="text-xs text-gray-400">${date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span>
</div>
<div class="flex items-center gap-4 mt-1 text-sm text-gray-500 dark:text-gray-400">
<span><i class="fas fa-clock mr-1"></i>${duration}</span>
${w.distance_meters > 0 ? `<span><i class="fas fa-route mr-1"></i>${distance} km</span>` : ''}
<span><i class="fas fa-fire mr-1"></i>${Math.round(w.calories)} kcal</span>
</div>
</div>
<div class="text-right">
<div class="text-xs text-gray-400">${date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}</div>
</div>
</div>
`;
}).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 = `
<div class="text-center text-gray-400 py-8">
<i class="fas fa-calendar-day text-4xl mb-2"></i>
<p>No workouts recorded today</p>
</div>
`;
} 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 `
<div class="flex items-start gap-4 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
<div class="w-12 h-12 rounded-xl ${color} flex items-center justify-center text-white shadow-md flex-shrink-0">
<i class="${icon} text-lg"></i>
</div>
<div class="flex-1">
<div class="flex justify-between items-start">
<div>
<h4 class="font-bold text-gray-900 dark:text-white">${w.activity_type}</h4>
<p class="text-sm text-gray-500">${date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}</p>
</div>
<span class="text-xs text-gray-400">${duration}</span>
</div>
<div class="flex gap-4 mt-2 text-sm text-gray-600 dark:text-gray-400">
${w.distance_meters > 0 ? `<span><i class="fas fa-route mr-1"></i>${distance} km</span>` : ''}
<span><i class="fas fa-fire mr-1"></i>${Math.round(w.calories)} kcal</span>
</div>
</div>
</div>
`;
}).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 += `<div class="text-center text-xs font-bold text-gray-400 p-2">${day}</div>`;
});
// Empty cells before first day
for (let i = 0; i < startingDayOfWeek; i++) {
html += `<div class="p-2"></div>`;
}
// 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 += `<div class="${cellClass}" title="${dayWorkouts.length} workout(s)">${day}</div>`;
}
calendar.innerHTML = html;
}