Files

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