4335 lines
160 KiB
JavaScript
4335 lines
160 KiB
JavaScript
const API_URL = '/api';
|
|
|
|
// --- Auth Check ---
|
|
const token = localStorage.getItem('token');
|
|
if (!token && window.location.pathname !== '/login.html') {
|
|
window.location.href = '/login.html';
|
|
}
|
|
|
|
// --- Fetch Wrapper ---
|
|
const originalFetch = window.fetch;
|
|
window.fetch = async (...args) => {
|
|
let [resource, config] = args;
|
|
config = config || {};
|
|
config.headers = config.headers || {};
|
|
|
|
// Add Token
|
|
const token = localStorage.getItem('token');
|
|
if (token) {
|
|
config.headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const response = await originalFetch(resource, config);
|
|
|
|
if (response.status === 401) {
|
|
localStorage.removeItem('token');
|
|
window.location.href = '/login.html';
|
|
}
|
|
|
|
return response;
|
|
};
|
|
|
|
// --- State ---
|
|
const state = {
|
|
challenges: [],
|
|
loading: false,
|
|
charts: {}, // Store chart instances
|
|
healthCache: {},
|
|
healthPeriod: 'week',
|
|
healthChart: null
|
|
};
|
|
|
|
// --- DOM Elements ---
|
|
const themeToggle = document.getElementById('theme-toggle');
|
|
const addChallengeBtn = document.getElementById('add-challenge-btn');
|
|
const modal = document.getElementById('add-challenge-modal');
|
|
const modalBackdrop = document.getElementById('modal-backdrop');
|
|
const closeModalBtn = document.getElementById('close-modal-btn');
|
|
const createForm = document.getElementById('create-challenge-form');
|
|
const challengesGrid = document.getElementById('challenges-grid');
|
|
const emptyState = document.getElementById('empty-state');
|
|
const totalRepsEl = document.getElementById('total-reps-today');
|
|
const activeStreakEl = document.getElementById('active-streak');
|
|
const completionRateEl = document.getElementById('completion-rate');
|
|
|
|
// New Elements for v2
|
|
const statsBtn = document.getElementById('stats-btn');
|
|
const exportBtn = document.getElementById('export-btn');
|
|
const statsModal = document.getElementById('stats-modal');
|
|
const closeStatsBtn = document.getElementById('close-stats-modal-btn');
|
|
const statsBackdrop = document.getElementById('stats-modal-backdrop');
|
|
|
|
// --- Initialization ---
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initTheme();
|
|
|
|
// Logout Handler
|
|
const logoutBtn = document.getElementById('logout-btn');
|
|
if (logoutBtn) {
|
|
logoutBtn.addEventListener('click', () => {
|
|
localStorage.removeItem('token');
|
|
window.location.href = '/login.html';
|
|
});
|
|
}
|
|
|
|
initDateInputs();
|
|
initSortable();
|
|
initImageDropdown();
|
|
initFrequencyHandlers();
|
|
initDatePickerModal(); // Date picker modal for health dashboard
|
|
// initGoogleSync(); // Removed
|
|
fetchChallenges();
|
|
// fetchHealthSummary(); // Removed
|
|
initHealthDashboard(); // New
|
|
});
|
|
|
|
// --- Theme Handling ---
|
|
function initTheme() {
|
|
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
|
|
themeToggle.addEventListener('click', () => {
|
|
if (document.documentElement.classList.contains('dark')) {
|
|
document.documentElement.classList.remove('dark');
|
|
localStorage.theme = 'light';
|
|
} else {
|
|
document.documentElement.classList.add('dark');
|
|
localStorage.theme = 'dark';
|
|
}
|
|
});
|
|
}
|
|
|
|
function initDateInputs() {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const inputs = document.querySelectorAll('input[type="date"]');
|
|
inputs.forEach(input => {
|
|
if (!input.value) input.value = today;
|
|
});
|
|
|
|
// Default end date to 30 days
|
|
const endDateInput = document.querySelector('input[name="end_date"]');
|
|
if (endDateInput) {
|
|
const future = new Date();
|
|
future.setDate(future.getDate() + 30);
|
|
endDateInput.value = future.toISOString().split('T')[0];
|
|
}
|
|
}
|
|
|
|
// --- Drag & Drop (SortableJS) ---
|
|
function initSortable() {
|
|
new Sortable(challengesGrid, {
|
|
animation: 150,
|
|
handle: '.drag-handle', // We will add this class to the card header
|
|
ghostClass: 'bg-indigo-50',
|
|
onEnd: function (evt) {
|
|
const itemEl = evt.item;
|
|
// Get new order
|
|
const newOrderIds = Array.from(challengesGrid.children).map(child => parseInt(child.dataset.id));
|
|
saveOrder(newOrderIds);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function saveOrder(orderedIds) {
|
|
try {
|
|
await fetch(`${API_URL}/challenges/reorder`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(orderedIds)
|
|
});
|
|
} catch (error) {
|
|
console.error('Error saving order:', error);
|
|
}
|
|
}
|
|
|
|
// --- Modal Handling ---
|
|
function toggleModal(el, show) {
|
|
const content = el.querySelector('div[class*="transform"]');
|
|
if (show) {
|
|
el.classList.remove('invisible', 'opacity-0');
|
|
content.classList.remove('scale-95');
|
|
content.classList.add('scale-100');
|
|
} else {
|
|
el.classList.add('invisible', 'opacity-0');
|
|
content.classList.add('scale-95');
|
|
content.classList.remove('scale-100');
|
|
}
|
|
}
|
|
|
|
// Rest Days Toggle
|
|
const restDaysToggle = document.getElementById('rest-days-toggle');
|
|
const restDaysSelector = document.getElementById('rest-days-selector');
|
|
const unitSelect = document.querySelector('select[name="unit_type"]');
|
|
const dailyTargetLabel = document.getElementById('daily-target-label');
|
|
|
|
if (unitSelect && dailyTargetLabel) {
|
|
unitSelect.addEventListener('change', (e) => {
|
|
const unit = e.target.options[e.target.selectedIndex].text;
|
|
dailyTargetLabel.innerText = `Daily Target (${unit})`;
|
|
});
|
|
}
|
|
|
|
// Frequency Handling
|
|
function initFrequencyHandlers() {
|
|
const periodSelect = document.getElementById('period-type-select');
|
|
const optionsContainer = document.getElementById('frequency-sub-options');
|
|
const dailyOptions = document.getElementById('daily-options');
|
|
const weeklyOptions = document.getElementById('weekly-options');
|
|
const monthlyOptions = document.getElementById('monthly-options');
|
|
|
|
if (!periodSelect) return;
|
|
|
|
function updateVisibility() {
|
|
const val = periodSelect.value;
|
|
dailyOptions.classList.add('hidden');
|
|
weeklyOptions.classList.add('hidden');
|
|
monthlyOptions.classList.add('hidden');
|
|
|
|
// Always show options container if there's something to show?
|
|
// Actually, we can just toggle the inner divs
|
|
if (val === 'daily') dailyOptions.classList.remove('hidden');
|
|
if (val === 'weekly') weeklyOptions.classList.remove('hidden');
|
|
if (val === 'monthly') monthlyOptions.classList.remove('hidden');
|
|
}
|
|
|
|
periodSelect.addEventListener('change', updateVisibility);
|
|
// Init
|
|
updateVisibility();
|
|
|
|
// Wire up old rest days logic to new rest days toggle for Daily
|
|
if (restDaysToggle) {
|
|
restDaysToggle.addEventListener('change', (e) => {
|
|
const selector = document.getElementById('rest-days-selector');
|
|
if (e.target.checked) {
|
|
selector.classList.remove('hidden');
|
|
selector.classList.add('grid');
|
|
} else {
|
|
selector.classList.add('hidden');
|
|
selector.classList.remove('grid');
|
|
selector.querySelectorAll('input').forEach(cb => cb.checked = false);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
addChallengeBtn.addEventListener('click', () => toggleModal(modal, true));
|
|
|
|
function resetModal() {
|
|
toggleModal(modal, false);
|
|
createForm.reset();
|
|
|
|
// Reset Frequency Logic
|
|
const periodSelect = document.getElementById('period-type-select');
|
|
if (periodSelect) {
|
|
periodSelect.value = 'daily';
|
|
periodSelect.dispatchEvent(new Event('change'));
|
|
}
|
|
|
|
if (restDaysToggle) {
|
|
restDaysToggle.checked = false;
|
|
// Trigger generic reset if needed
|
|
const selector = document.getElementById('rest-days-selector');
|
|
if (selector) {
|
|
selector.classList.add('hidden');
|
|
selector.classList.remove('grid');
|
|
selector.querySelectorAll('input').forEach(cb => cb.checked = false);
|
|
}
|
|
}
|
|
|
|
// Clear Weekly
|
|
document.querySelectorAll('input[name="weekly_days"]').forEach(cb => cb.checked = false);
|
|
|
|
// Clear Monthly
|
|
const monthlyInput = document.querySelector('input[name="frequency_count"]');
|
|
if (monthlyInput) monthlyInput.value = '';
|
|
|
|
// Reset icon to default
|
|
const defaultIcon = document.querySelector('input[name="icon"][value="fa-dumbbell"]');
|
|
if (defaultIcon) defaultIcon.checked = true;
|
|
|
|
// Reset Image Dropdown
|
|
const previewContainer = document.getElementById('image-preview-container');
|
|
const placeholder = document.getElementById('no-image-placeholder');
|
|
const selectedText = document.getElementById('dropdown-selected-text');
|
|
const hiddenInput = document.getElementById('selected-image-url');
|
|
const imgPreview = document.getElementById('image-preview');
|
|
|
|
if (previewContainer) previewContainer.classList.add('hidden');
|
|
if (placeholder) placeholder.classList.remove('hidden');
|
|
if (selectedText) selectedText.textContent = 'Select animation...';
|
|
if (hiddenInput) hiddenInput.value = '';
|
|
if (imgPreview) imgPreview.src = '';
|
|
|
|
initDateInputs();
|
|
}
|
|
|
|
closeModalBtn.addEventListener('click', resetModal);
|
|
modalBackdrop.addEventListener('click', resetModal);
|
|
|
|
// Stats Modal
|
|
// Stats Modal
|
|
if (statsBtn) {
|
|
statsBtn.addEventListener('click', () => {
|
|
toggleModal(statsModal, true);
|
|
renderCharts();
|
|
});
|
|
}
|
|
if (closeStatsBtn) closeStatsBtn.addEventListener('click', () => toggleModal(statsModal, false));
|
|
if (statsBackdrop) statsBackdrop.addEventListener('click', () => toggleModal(statsModal, false));
|
|
|
|
// --- API Interactions ---
|
|
async function fetchChallenges() {
|
|
try {
|
|
const response = await fetch(`${API_URL}/challenges`);
|
|
if (!response.ok) throw new Error('Failed to fetch challenges');
|
|
state.challenges = await response.json();
|
|
renderDashboard();
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
}
|
|
}
|
|
|
|
createForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(createForm);
|
|
const data = Object.fromEntries(formData.entries());
|
|
data.daily_target = parseInt(data.daily_target);
|
|
if (!data.end_date) data.end_date = null;
|
|
|
|
// Dynamic Data Packing based on Frequency
|
|
if (data.period_type === 'daily') {
|
|
const restDays = [];
|
|
const selector = document.getElementById('rest-days-selector');
|
|
if (selector) {
|
|
selector.querySelectorAll('input:checked').forEach(cb => restDays.push(cb.value));
|
|
}
|
|
data.rest_days = restDays.length > 0 ? restDays.join(',') : null;
|
|
data.selected_days = null;
|
|
data.frequency_count = null;
|
|
} else if (data.period_type === 'weekly') {
|
|
const weeklyDays = [];
|
|
document.querySelectorAll('input[name="weekly_days"]:checked').forEach(cb => weeklyDays.push(cb.value));
|
|
data.selected_days = weeklyDays.length > 0 ? weeklyDays.join(',') : null;
|
|
data.rest_days = null;
|
|
data.frequency_count = null;
|
|
} else if (data.period_type === 'monthly') {
|
|
data.frequency_count = parseInt(data.frequency_count) || null;
|
|
data.rest_days = null;
|
|
data.selected_days = null;
|
|
}
|
|
|
|
// unit_type is already in formData (select name="unit_type")
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}/challenges`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to create challenge');
|
|
await fetchChallenges();
|
|
resetModal(); // Use unified reset
|
|
} catch (error) {
|
|
console.error('Error creating challenge:', error);
|
|
}
|
|
});
|
|
|
|
async function updateTracking(challengeId, dateStr, reps) {
|
|
const challenge = state.challenges.find(c => c.id === challengeId);
|
|
if (!challenge) return;
|
|
|
|
const completed = reps >= challenge.daily_target;
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}/tracking`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
challenge_id: challengeId,
|
|
date: dateStr,
|
|
reps: reps,
|
|
completed: completed
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to update tracking');
|
|
await fetchChallenges();
|
|
|
|
} catch (error) {
|
|
console.error('Error updating tracking:', error);
|
|
}
|
|
}
|
|
|
|
async function deleteChallenge(id) {
|
|
if (!confirm('Are you sure you want to delete this challenge?')) return;
|
|
try {
|
|
const response = await fetch(`${API_URL}/challenges/${id}`, { method: 'DELETE' });
|
|
if (!response.ok) throw new Error('Failed to delete');
|
|
await fetchChallenges();
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
}
|
|
}
|
|
|
|
// --- Data Export ---
|
|
exportBtn.addEventListener('click', () => {
|
|
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(state.challenges, null, 2));
|
|
const downloadAnchorNode = document.createElement('a');
|
|
downloadAnchorNode.setAttribute("href", dataStr);
|
|
downloadAnchorNode.setAttribute("download", "habitforge_data.json");
|
|
document.body.appendChild(downloadAnchorNode); // required for firefox
|
|
downloadAnchorNode.click();
|
|
downloadAnchorNode.remove();
|
|
});
|
|
|
|
// --- Gamification (Badges) ---
|
|
function getBadges(challenge) {
|
|
const badges = [];
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
// Calculate total reps
|
|
let totalReps = 0;
|
|
let daysTracked = 0;
|
|
let daysCompleted = 0;
|
|
let currentStreak = 0;
|
|
|
|
// Sort trackings by date descending
|
|
const sortedTrackings = [...challenge.trackings].sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
|
|
// Calculate Stats
|
|
sortedTrackings.forEach(t => {
|
|
totalReps += t.reps;
|
|
if (t.reps > 0) daysTracked++;
|
|
if (t.completed) daysCompleted++;
|
|
});
|
|
|
|
// Calculate Streak (consecutive days with > 0 reps ending today or yesterday)
|
|
// Simplified logic: Check yesterday, day before...
|
|
// Ideally we iterate dates back from today.
|
|
let checkDate = new Date();
|
|
// Allow missing today if checked yesterday
|
|
|
|
// Let's rely on sortedTrackings for simple check
|
|
// This is a naive streak implementation for demo
|
|
for (let i = 0; i < sortedTrackings.length; i++) {
|
|
const t = sortedTrackings[i];
|
|
// Logic is complex without a full date map.
|
|
// Let's simplifiy: if recent 7 days have 7 completions -> Perfect Week
|
|
}
|
|
|
|
// 100 Reps Club
|
|
if (totalReps >= 100) {
|
|
badges.push({ icon: 'fa-dumbbell', color: 'text-blue-500', title: '100 Reps Club' });
|
|
}
|
|
|
|
// High Volume (1000 Reps)
|
|
if (totalReps >= 1000) {
|
|
badges.push({ icon: 'fa-trophy', color: 'text-yellow-500', title: '1k Legend' });
|
|
}
|
|
|
|
// Consistency (Example: 5+ completions)
|
|
if (daysCompleted >= 5) {
|
|
badges.push({ icon: 'fa-fire', color: 'text-orange-500', title: 'On Fire (5+ Days)' });
|
|
}
|
|
|
|
return badges;
|
|
}
|
|
|
|
// --- Rendering ---
|
|
function renderDashboard() {
|
|
updateStats();
|
|
renderBadges();
|
|
|
|
|
|
if (state.challenges.length === 0) {
|
|
challengesGrid.innerHTML = '';
|
|
emptyState.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
emptyState.classList.add('hidden');
|
|
challengesGrid.innerHTML = '';
|
|
|
|
state.challenges.forEach((challenge, index) => {
|
|
const card = createChallengeCard(challenge);
|
|
challengesGrid.appendChild(card);
|
|
|
|
anime({
|
|
targets: card,
|
|
translateY: [20, 0],
|
|
opacity: [0, 1],
|
|
delay: index * 50,
|
|
easing: 'easeOutQuad',
|
|
duration: 300
|
|
});
|
|
});
|
|
}
|
|
|
|
function updateStats() {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
let totalReps = 0;
|
|
let completedCount = 0;
|
|
let trackedCount = 0;
|
|
|
|
state.challenges.forEach(c => {
|
|
const t = c.trackings.find(t => t.date === today);
|
|
if (t) {
|
|
totalReps += t.reps;
|
|
if (t.completed) completedCount++;
|
|
if (t.reps > 0) trackedCount++;
|
|
}
|
|
});
|
|
|
|
// Update Header Numbers
|
|
totalRepsEl.innerText = totalReps;
|
|
activeStreakEl.innerText = trackedCount; // Simply Tracked Today for now
|
|
|
|
const rate = state.challenges.length > 0 ? Math.round((completedCount / state.challenges.length) * 100) : 0;
|
|
completionRateEl.innerText = rate + '%';
|
|
}
|
|
|
|
function createChallengeCard(challenge) {
|
|
const div = document.createElement('div');
|
|
div.className = 'bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden';
|
|
div.dataset.id = challenge.id; // Corrected dataset assignment
|
|
|
|
// Get Badges
|
|
const badges = getBadges(challenge);
|
|
const badgesHtml = badges.map(b => `<i class="fas ${b.icon} ${b.color} mx-1" title="${b.title}"></i>`).join('');
|
|
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const todayTracking = challenge.trackings.find(t => t.date === today) || { reps: 0 };
|
|
const progressPercent = Math.min(100, (todayTracking.reps / challenge.daily_target) * 100);
|
|
|
|
// Icon handling
|
|
const iconClass = challenge.icon || 'fa-dumbbell';
|
|
|
|
// Image handling
|
|
let imageHtml = '';
|
|
// Layout: Horizontal Card
|
|
// Left: Image (or Icon placeholder if no image? No, prompt implies image available. If no image, existing layout preferred? User said "put image on left". I will assume this layout for image ones.)
|
|
|
|
const hasImage = !!challenge.image_url;
|
|
|
|
div.innerHTML = `
|
|
<div class="flex flex-col sm:flex-row h-full">
|
|
<!-- Left Side: Visual -->
|
|
<div class="relative w-full sm:w-64 sm:min-h-full h-32 flex-shrink-0 bg-gray-100 dark:bg-gray-900 group overflow-hidden">
|
|
${hasImage ? `
|
|
<img src="${challenge.image_url}" alt="${challenge.name}" class="w-full h-full object-cover object-center transition-transform duration-500 group-hover:scale-105">
|
|
` : `
|
|
<div class="w-full h-full flex items-center justify-center bg-brand-50 dark:bg-brand-900/20 text-brand-500 dark:text-brand-400">
|
|
<i class="fas ${iconClass} text-5xl opacity-50"></i>
|
|
</div>
|
|
`}
|
|
|
|
<!-- Floating Badges -->
|
|
<div class="absolute top-2 left-2 flex flex-col items-start gap-1 z-10">
|
|
<span class="text-[10px] font-bold uppercase tracking-widest bg-black/50 text-white px-2 py-0.5 rounded backdrop-blur-md shadow-sm border border-white/10">${challenge.period_type}</span>
|
|
${hasImage ? `
|
|
<span class="p-1.5 bg-black/50 backdrop-blur-md rounded-lg text-white border border-white/10">
|
|
<i class="fas ${iconClass}"></i>
|
|
</span>` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Side: Content -->
|
|
<div class="flex-1 p-4 flex flex-col justify-between min-w-0">
|
|
<div>
|
|
<div class="flex justify-between items-start mb-2 drag-handle cursor-move">
|
|
<div class="mr-4 min-w-0 flex-1">
|
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white leading-tight truncate" title="${challenge.name}">${challenge.name}</h3>
|
|
${challenge.description ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2" title="${challenge.description}">${challenge.description}</p>` : ''}
|
|
</div>
|
|
<button class="delete-btn text-gray-400 hover:text-red-500 transition-colors flex-shrink-0" data-id="${challenge.id}">
|
|
<i class="fas fa-trash-alt"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<span class="text-xs font-semibold text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/30 px-2 py-0.5 rounded border border-brand-100 dark:border-brand-800">Target: ${challenge.daily_target} ${challenge.unit_type || 'reps'}</span>
|
|
<div class="flex space-x-1">${badgesHtml}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress & Controls -->
|
|
<div class="bg-gray-50 dark:bg-gray-700/30 rounded-lg p-3 border border-gray-100 dark:border-gray-700/50">
|
|
<div class="flex justify-between items-center mb-1.5">
|
|
<span class="text-[10px] uppercase font-bold text-gray-500">Today</span>
|
|
<div class="flex items-center gap-1">
|
|
<span class="text-xs font-bold ${progressPercent >= 100 ? 'text-green-500' : 'text-gray-900 dark:text-white'}">${todayTracking.reps}</span>
|
|
<span class="text-[10px] text-gray-400">/ ${challenge.daily_target}</span>
|
|
</div>
|
|
</div>
|
|
<div class="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-1.5 mb-3 overflow-hidden">
|
|
<div class="bg-brand-500 h-full rounded-full transition-all duration-500" style="width: ${progressPercent}%"></div>
|
|
</div>
|
|
|
|
<div class="flex justify-between items-center">
|
|
<div class="flex gap-1">
|
|
<button class="w-6 h-6 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 hover:border-brand-500 text-gray-400 hover:text-brand-500 transition-colors flex items-center justify-center active:scale-95"
|
|
onclick="adjustReps(${challenge.id}, '${today}', -1, ${todayTracking.reps})"><i class="fas fa-minus text-[10px]"></i></button>
|
|
<button class="w-6 h-6 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 hover:border-brand-500 text-gray-400 hover:text-brand-500 transition-colors flex items-center justify-center active:scale-95"
|
|
onclick="adjustReps(${challenge.id}, '${today}', 1, ${todayTracking.reps})"><i class="fas fa-plus text-[10px]"></i></button>
|
|
</div>
|
|
<div class="flex gap-1">
|
|
<button onclick="adjustReps(${challenge.id}, '${today}', 10, ${todayTracking.reps})" class="px-2 py-0.5 rounded text-[10px] font-bold bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors">+10</button>
|
|
<button onclick="adjustReps(${challenge.id}, '${today}', 25, ${todayTracking.reps})" class="px-2 py-0.5 rounded text-[10px] font-bold bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors">+25</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer: History -->
|
|
<div class="mt-3 flex items-center justify-end gap-1">
|
|
<span class="text-[9px] uppercase text-gray-400 mr-1">History</span>
|
|
${generateMiniHeatmap(challenge)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
div.querySelector('.delete-btn').addEventListener('click', () => deleteChallenge(challenge.id));
|
|
return div;
|
|
}
|
|
|
|
window.adjustReps = (id, date, delta, current) => {
|
|
const newReps = Math.max(0, current + delta);
|
|
updateTracking(id, date, newReps);
|
|
};
|
|
|
|
window.manualReps = (id, date, value) => {
|
|
const newReps = Math.max(0, parseInt(value) || 0);
|
|
updateTracking(id, date, newReps);
|
|
};
|
|
|
|
window.toggleTagsInput = () => {
|
|
const container = document.getElementById('tags-input-container');
|
|
if (container) container.classList.toggle('hidden');
|
|
};
|
|
|
|
async function saveContext() {
|
|
const slider = document.getElementById('readiness-slider');
|
|
const tagsInput = document.getElementById('context-tags-input');
|
|
const dateStr = state.currentDate.toISOString().split('T')[0];
|
|
|
|
const payload = {
|
|
date: dateStr,
|
|
mood: parseInt(slider.value),
|
|
tags: tagsInput.value,
|
|
notes: null
|
|
};
|
|
|
|
try {
|
|
const res = await fetch(`${API_URL}/analytics/context`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (res.ok) {
|
|
// Toast or feedback
|
|
const btn = document.getElementById('save-context-btn');
|
|
const originalText = btn.innerText;
|
|
btn.innerText = 'Saved!';
|
|
btn.classList.add('bg-green-600');
|
|
setTimeout(() => {
|
|
btn.innerText = originalText;
|
|
btn.classList.remove('bg-green-600');
|
|
}, 2000);
|
|
|
|
// Reload data to refresh readiness with new context
|
|
loadHealthData(state.healthPeriod);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to save context", e);
|
|
}
|
|
}
|
|
|
|
// Bind save button (delegate or init)
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target && e.target.id === 'save-context-btn') {
|
|
saveContext();
|
|
}
|
|
});
|
|
|
|
function generateMiniHeatmap(challenge) {
|
|
let html = '';
|
|
const today = new Date();
|
|
// Show last 7 days + today (total 8? or just 7). Let's do 7.
|
|
for (let i = 6; i >= 0; i--) {
|
|
const d = new Date(today);
|
|
d.setDate(d.getDate() - i);
|
|
const dateStr = d.toISOString().split('T')[0];
|
|
|
|
const tracking = challenge.trackings.find(t => t.date === dateStr);
|
|
let colorClass = 'bg-gray-200 dark:bg-gray-700';
|
|
|
|
if (tracking) {
|
|
if (tracking.completed) colorClass = 'bg-green-500';
|
|
else if (tracking.reps > 0) colorClass = 'bg-orange-400';
|
|
else if (new Date(dateStr) < new Date(today.toISOString().split('T')[0])) colorClass = 'bg-red-400/50';
|
|
}
|
|
|
|
const dayName = d.toLocaleDateString('en-US', { weekday: 'narrow' });
|
|
// Tooltip with date and status
|
|
const title = `${dateStr}: ${tracking ? tracking.reps : 0} `;
|
|
|
|
html += `
|
|
<div class="group relative flex flex-col items-center cursor-help">
|
|
<div class="w-2.5 h-2.5 rounded-full ${colorClass}" title="${title}"></div>
|
|
</div>
|
|
`;
|
|
}
|
|
return html;
|
|
}
|
|
|
|
// --- Charts Logic ---
|
|
function renderCharts() {
|
|
const ctxReps = document.getElementById('chart-reps').getContext('2d');
|
|
const ctxComp = document.getElementById('chart-completion').getContext('2d');
|
|
|
|
// Prepare Data: Last 7 days
|
|
const labels = [];
|
|
const today = new Date();
|
|
for (let i = 6; i >= 0; i--) {
|
|
const d = new Date(today);
|
|
d.setDate(d.getDate() - i);
|
|
labels.push(d.toLocaleDateString('en-US', { weekday: 'short' }));
|
|
}
|
|
|
|
// Aggregates
|
|
const repsData = new Array(7).fill(0);
|
|
const completionData = new Array(7).fill(0);
|
|
|
|
// Naively iterate again for simplicity
|
|
for (let i = 6; i >= 0; i--) {
|
|
const d = new Date(today);
|
|
d.setDate(d.getDate() - i);
|
|
const dateStr = d.toISOString().split('T')[0];
|
|
const idx = 6 - i;
|
|
|
|
state.challenges.forEach(c => {
|
|
const t = c.trackings.find(tr => tr.date === dateStr);
|
|
if (t) {
|
|
repsData[idx] += t.reps;
|
|
if (t.completed) completionData[idx] += 1;
|
|
}
|
|
});
|
|
// Convert count to percentage for completion
|
|
if (state.challenges.length > 0) {
|
|
completionData[idx] = Math.round((completionData[idx] / state.challenges.length) * 100);
|
|
}
|
|
}
|
|
|
|
// Destroy existing
|
|
if (state.charts.reps) state.charts.reps.destroy();
|
|
if (state.charts.comp) state.charts.comp.destroy();
|
|
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const textColor = isDark ? '#9ca3af' : '#4b5563';
|
|
|
|
state.charts.reps = new Chart(ctxReps, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Total Reps',
|
|
data: repsData,
|
|
backgroundColor: '#0ea5e9',
|
|
borderRadius: 4
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
y: { beginAtZero: true, grid: { color: isDark ? '#374151' : '#e5e7eb' }, ticks: { color: textColor } },
|
|
x: { grid: { display: false }, ticks: { color: textColor } }
|
|
},
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
|
|
state.charts.comp = new Chart(ctxComp, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Completion Rate %',
|
|
data: completionData,
|
|
borderColor: '#10b981',
|
|
tension: 0.4,
|
|
fill: true,
|
|
backgroundColor: '#10b98120'
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
y: { beginAtZero: true, max: 100, grid: { color: isDark ? '#374151' : '#e5e7eb' }, ticks: { color: textColor } },
|
|
x: { grid: { display: false }, ticks: { color: textColor } }
|
|
},
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
|
|
renderHeatmap();
|
|
}
|
|
|
|
function renderBadges() {
|
|
const container = document.getElementById('badge-list');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = '';
|
|
|
|
// Calculate Global Stats
|
|
let totalReps = 0;
|
|
let maxDailyReps = 0;
|
|
let daysCompletedChain = 0; // Simple streak logic
|
|
let hasChallenge = state.challenges.length > 0;
|
|
|
|
// We need to aggregate trackings by date to find max daily reps
|
|
const dailyRepsMap = {};
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
state.challenges.forEach(c => {
|
|
c.trackings.forEach(t => {
|
|
totalReps += t.reps;
|
|
if (!dailyRepsMap[t.date]) dailyRepsMap[t.date] = 0;
|
|
dailyRepsMap[t.date] += t.reps;
|
|
});
|
|
});
|
|
|
|
Object.values(dailyRepsMap).forEach(reps => {
|
|
if (reps > maxDailyReps) maxDailyReps = reps;
|
|
});
|
|
|
|
// Check Streak (Global: consecutive days with at least one completion?)
|
|
// Prompt says "Active Streak de 7 jours".
|
|
// We'll calculate it from "Active Streak" logic we already have or re-calculate.
|
|
// Existing activeStreakEl suggests we might have it, but that logic was "Tracked Today".
|
|
// Let's implement a simple "Last 7 days active" check for the badge.
|
|
|
|
// Badge Definitions
|
|
const badges = [
|
|
{
|
|
id: 'beginner',
|
|
title: 'Débutant',
|
|
desc: 'Créer le premier challenge',
|
|
icon: 'fa-rocket',
|
|
unlocked: hasChallenge
|
|
},
|
|
{
|
|
id: 'onfire',
|
|
title: 'En feu',
|
|
desc: 'Atteindre une streak de 7 jours',
|
|
icon: 'fa-fire',
|
|
unlocked: parseInt(document.getElementById('active-streak').innerText) >= 7 // Reusing existing metric for simplicity or calculate proper streak
|
|
},
|
|
{
|
|
id: 'spartan',
|
|
title: 'Spartiate',
|
|
desc: '300 reps en une seule journée',
|
|
icon: 'fa-shield-halved',
|
|
unlocked: maxDailyReps >= 300
|
|
}
|
|
];
|
|
|
|
badges.forEach(b => {
|
|
const div = document.createElement('div');
|
|
div.className = `flex flex-col items-center justify-center p-3 rounded-lg border w-24 flex-shrink-0 transition-all ${b.unlocked ? 'bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800' : 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 opacity-50 grayscale'}`;
|
|
div.title = b.desc;
|
|
|
|
div.innerHTML = `
|
|
<div class="w-10 h-10 rounded-full flex items-center justify-center mb-2 ${b.unlocked ? 'bg-brand-100 dark:bg-brand-800 text-brand-600 dark:text-brand-300' : 'bg-gray-200 dark:bg-gray-700 text-gray-400'}">
|
|
<i class="fas ${b.icon} text-lg"></i>
|
|
</div>
|
|
<span class="text-xs font-semibold text-center ${b.unlocked ? 'text-brand-700 dark:text-brand-300' : 'text-gray-500'}">${b.title}</span>
|
|
`;
|
|
|
|
container.appendChild(div);
|
|
});
|
|
}
|
|
|
|
|
|
|
|
function renderHeatmap() {
|
|
const container = document.getElementById('consistency-heatmap');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = '';
|
|
const grid = document.createElement('div');
|
|
grid.className = 'flex flex-wrap gap-1 max-w-full justify-start';
|
|
|
|
const today = new Date();
|
|
// Start 365 days ago
|
|
const startDate = new Date(today);
|
|
startDate.setDate(today.getDate() - 364);
|
|
|
|
for (let i = 0; i < 365; i++) {
|
|
const d = new Date(startDate);
|
|
d.setDate(startDate.getDate() + i);
|
|
const dateStr = d.toISOString().split('T')[0];
|
|
|
|
let activeChallengesCount = 0;
|
|
let completedCount = 0;
|
|
let anyActivity = false;
|
|
|
|
state.challenges.forEach(c => {
|
|
if (c.start_date <= dateStr && (!c.end_date || c.end_date >= dateStr)) {
|
|
activeChallengesCount++;
|
|
const t = c.trackings.find(tr => tr.date === dateStr);
|
|
if (t) {
|
|
if (t.reps > 0) anyActivity = true;
|
|
if (t.completed) completedCount++;
|
|
}
|
|
}
|
|
});
|
|
|
|
let colorClass = 'bg-gray-100 dark:bg-gray-700/50';
|
|
let title = `${dateStr}: No activity`;
|
|
|
|
if (activeChallengesCount > 0) {
|
|
if (completedCount === activeChallengesCount && activeChallengesCount > 0) {
|
|
colorClass = 'bg-brand-500';
|
|
title = `${dateStr}: Perfect Day! (${completedCount}/${activeChallengesCount})`;
|
|
} else if (anyActivity) {
|
|
colorClass = 'bg-brand-300 dark:bg-brand-800';
|
|
title = `${dateStr}: Some activity`;
|
|
} else {
|
|
title = `${dateStr}: Missed`;
|
|
}
|
|
}
|
|
|
|
const square = document.createElement('div');
|
|
square.className = `w-2.5 h-2.5 rounded-sm ${colorClass} transition-colors hover:ring-1 hover:ring-gray-400`;
|
|
square.title = title;
|
|
grid.appendChild(square);
|
|
}
|
|
container.appendChild(grid);
|
|
}
|
|
|
|
// --- Google Fit & Health Dashboard ---
|
|
|
|
// Google Fit & Health Dashboard Legacy Code Removed
|
|
|
|
|
|
function renderHealthDashboard(data) {
|
|
const section = document.getElementById('health-overview');
|
|
if (!data || data.length === 0) {
|
|
// section.classList.add('hidden'); // Keep visible so user sees Sync button?
|
|
// Or show empty state
|
|
document.getElementById('health-steps').innerText = '--';
|
|
document.getElementById('health-sleep').innerText = '--';
|
|
document.getElementById('health-pai').innerText = '--';
|
|
return;
|
|
}
|
|
section.classList.remove('hidden');
|
|
|
|
// 1. Update Cards (Latest Data)
|
|
// Sort logic in backend was "asc". So last item is latest.
|
|
const latest = data[data.length - 1];
|
|
const todayStr = new Date().toISOString().split('T')[0];
|
|
|
|
// Check if latest is today? If not, show latest available with date?
|
|
// For now, just show latest sync point.
|
|
|
|
if (latest) {
|
|
document.getElementById('health-steps').innerText = latest.step_count.toLocaleString();
|
|
|
|
const h = Math.floor(latest.sleep_duration_minutes / 60);
|
|
const m = latest.sleep_duration_minutes % 60;
|
|
document.getElementById('health-sleep').innerText = `${h}h ${m}m`;
|
|
|
|
const dh = Math.floor(latest.deep_sleep_minutes / 60);
|
|
const dm = latest.deep_sleep_minutes % 60;
|
|
document.getElementById('health-deep_sleep').innerText = `${dh}h ${dm}m Deep`; // Typo fix in HTML id needed? HTML has health-deep-sleep
|
|
|
|
document.getElementById('health-pai').innerText = latest.pai_score.toFixed(1);
|
|
|
|
// Update bar width (Goal: 10k steps?)
|
|
const stepGoal = 10000;
|
|
const pct = Math.min(100, (latest.step_count / stepGoal) * 100);
|
|
document.getElementById('steps-bar').style.width = `${pct}%`;
|
|
}
|
|
|
|
// 2. Render Charts
|
|
// Prepare Data
|
|
const labels = data.map(d => new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
|
|
const stepsData = data.map(d => d.step_count);
|
|
const calsData = data.map(d => d.calories_burned);
|
|
const sleepData = data.map(d => Number((d.sleep_duration_minutes / 60).toFixed(1)));
|
|
const paiData = data.map(d => d.pai_score);
|
|
|
|
// Chart 1: Steps & Calories
|
|
const ctx1 = document.getElementById('chart-steps-cals').getContext('2d');
|
|
if (state.charts.health1) state.charts.health1.destroy();
|
|
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const textColor = isDark ? '#9ca3af' : '#4b5563';
|
|
|
|
state.charts.health1 = new Chart(ctx1, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Steps',
|
|
data: stepsData,
|
|
backgroundColor: '#3b82f6',
|
|
yAxisID: 'y'
|
|
},
|
|
{
|
|
label: 'Calories',
|
|
data: calsData,
|
|
type: 'line',
|
|
borderColor: '#ef4444',
|
|
borderWidth: 2,
|
|
pointRadius: 0,
|
|
tension: 0.4,
|
|
yAxisID: 'y1'
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { ticks: { color: textColor }, grid: { display: false } },
|
|
y: { type: 'linear', position: 'left', ticks: { color: textColor }, grid: { color: isDark ? '#374151' : '#e5e7eb' } },
|
|
y1: { type: 'linear', position: 'right', grid: { display: false }, ticks: { display: false } } // Hide calories ticks to reduce clutter
|
|
}
|
|
}
|
|
});
|
|
|
|
// Chart 2: Sleep & PAI
|
|
const ctx2 = document.getElementById('chart-health-metrics').getContext('2d');
|
|
if (state.charts.health2) state.charts.health2.destroy();
|
|
|
|
state.charts.health2 = new Chart(ctx2, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Sleep (hrs)',
|
|
data: sleepData,
|
|
borderColor: '#8b5cf6',
|
|
backgroundColor: '#8b5cf620',
|
|
fill: true,
|
|
tension: 0.4,
|
|
yAxisID: 'y'
|
|
},
|
|
{
|
|
label: 'PAI',
|
|
data: paiData,
|
|
borderColor: '#f97316',
|
|
borderDash: [5, 5],
|
|
tension: 0.4,
|
|
yAxisID: 'y1'
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { ticks: { color: textColor }, grid: { display: false } },
|
|
y: { title: { display: true, text: 'Hours' }, ticks: { color: textColor }, grid: { color: isDark ? '#374151' : '#e5e7eb' } },
|
|
y1: { type: 'linear', position: 'right', grid: { display: false }, title: { display: true, text: 'PAI' } }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
|
|
// --- Custom Image Dropdown ---
|
|
async function initImageDropdown() {
|
|
const dropdown = document.getElementById('custom-image-dropdown');
|
|
if (!dropdown) return;
|
|
|
|
const button = document.getElementById('dropdown-button');
|
|
const menu = document.getElementById('dropdown-menu');
|
|
const optionsContainer = document.getElementById('dropdown-options');
|
|
const hiddenInput = document.getElementById('selected-image-url');
|
|
const previewContainer = document.getElementById('image-preview-container');
|
|
const previewImage = document.getElementById('image-preview');
|
|
const selectedText = document.getElementById('dropdown-selected-text');
|
|
|
|
// Fetch images
|
|
try {
|
|
const response = await fetch('/api/static/images');
|
|
if (!response.ok) return;
|
|
const images = await response.json();
|
|
|
|
if (images.length === 0) {
|
|
optionsContainer.innerHTML = '<div class="p-2 text-sm text-gray-500">No images found</div>';
|
|
} else {
|
|
optionsContainer.innerHTML = '';
|
|
images.forEach(img => {
|
|
const url = `/static/${img}`;
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'flex items-center gap-3 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg cursor-pointer transition-colors';
|
|
div.onclick = () => selectImage(url, img);
|
|
div.innerHTML = `
|
|
<div class="h-10 w-10 flex-shrink-0 bg-gray-200 dark:bg-gray-600 rounded overflow-hidden">
|
|
<img src="${url}" class="h-full w-full object-cover">
|
|
</div>
|
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200 truncate">${img}</span>
|
|
`;
|
|
optionsContainer.appendChild(div);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load images", e);
|
|
}
|
|
|
|
// Toggle
|
|
button.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
menu.classList.toggle('hidden');
|
|
const arrow = button.querySelector('.fa-chevron-down');
|
|
if (arrow) arrow.classList.toggle('rotate-180');
|
|
});
|
|
|
|
// Close on click outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!dropdown.contains(e.target)) {
|
|
menu.classList.add('hidden');
|
|
const arrow = button.querySelector('.fa-chevron-down');
|
|
if (arrow) arrow.classList.remove('rotate-180');
|
|
}
|
|
});
|
|
|
|
function selectImage(url, name) {
|
|
hiddenInput.value = url;
|
|
selectedText.textContent = name;
|
|
previewImage.src = url;
|
|
|
|
previewContainer.classList.remove('hidden');
|
|
const placeholder = document.getElementById('no-image-placeholder');
|
|
if (placeholder) placeholder.classList.add('hidden');
|
|
|
|
menu.classList.add('hidden');
|
|
const arrow = button.querySelector('.fa-chevron-down');
|
|
if (arrow) arrow.classList.remove('rotate-180');
|
|
}
|
|
}
|
|
|
|
// --- Health Dashboard Logic ---
|
|
|
|
// --- Health Dashboard Logic ---
|
|
|
|
function initHealthDashboard() {
|
|
// Initialize state properties
|
|
if (!state.healthCache) state.healthCache = {};
|
|
if (!state.charts.health) state.charts.health = {}; // Store multiple health charts
|
|
if (!state.healthPeriod) state.healthPeriod = 'week';
|
|
if (!state.currentDate) state.currentDate = new Date(); // Default to today
|
|
|
|
const toggleBtn = document.getElementById('health-toggle-btn');
|
|
const dashboardView = document.getElementById('dashboard-view');
|
|
const healthView = document.getElementById('health-dashboard-view');
|
|
const timeframeBtns = document.querySelectorAll('.timeframe-btn');
|
|
// Sync buttons removed
|
|
const tabBtns = document.querySelectorAll('.health-tab-btn');
|
|
|
|
// specific date nav
|
|
const datePrevBtn = document.getElementById('date-prev-btn');
|
|
const dateNextBtn = document.getElementById('date-next-btn');
|
|
|
|
// Toggle View
|
|
if (toggleBtn) {
|
|
toggleBtn.addEventListener('click', () => {
|
|
const isHealth = healthView.classList.contains('hidden');
|
|
if (isHealth) {
|
|
dashboardView.classList.add('hidden');
|
|
healthView.classList.remove('hidden');
|
|
toggleBtn.classList.add('bg-brand-50', 'text-brand-600');
|
|
toggleBtn.classList.remove('bg-white', 'text-gray-600');
|
|
|
|
// Reset date on open if needed, or keep last state? keeping last state is better.
|
|
updateDateDisplay();
|
|
switchPeriodViews(state.healthPeriod);
|
|
loadHealthData(state.healthPeriod);
|
|
|
|
// Initialize Tabs if first open (default to overview)
|
|
if (!document.querySelector('.health-tab-btn.active-tab')) {
|
|
tabBtns[0].click();
|
|
}
|
|
} else {
|
|
healthView.classList.add('hidden');
|
|
dashboardView.classList.remove('hidden');
|
|
toggleBtn.classList.remove('bg-brand-50', 'text-brand-600');
|
|
toggleBtn.classList.add('bg-white', 'text-gray-600');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Date Navigation Listeners
|
|
if (datePrevBtn) datePrevBtn.addEventListener('click', () => changeDate(-1));
|
|
if (dateNextBtn) dateNextBtn.addEventListener('click', () => changeDate(1));
|
|
|
|
// Timeframe Selectors
|
|
timeframeBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
timeframeBtns.forEach(b => {
|
|
b.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400');
|
|
b.classList.add('text-gray-500', 'dark:text-gray-400');
|
|
});
|
|
btn.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400');
|
|
btn.classList.remove('text-gray-500', 'dark:text-gray-400');
|
|
|
|
state.healthPeriod = btn.dataset.period;
|
|
|
|
// Switch period-specific views within each tab
|
|
switchPeriodViews(state.healthPeriod);
|
|
|
|
updateDateDisplay();
|
|
loadHealthData(state.healthPeriod);
|
|
});
|
|
});
|
|
|
|
// Tab Switching
|
|
tabBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
// Update Buttons
|
|
tabBtns.forEach(b => {
|
|
b.classList.remove('active-tab', 'text-brand-600', 'border-brand-600');
|
|
b.classList.add('text-gray-500', 'dark:text-gray-400', 'border-transparent');
|
|
});
|
|
btn.classList.add('active-tab', 'text-brand-600', 'border-brand-600');
|
|
btn.classList.remove('text-gray-500', 'dark:text-gray-400', 'border-transparent');
|
|
|
|
// Show Content
|
|
document.querySelectorAll('.health-tab-content').forEach(c => c.classList.add('hidden'));
|
|
const tabId = btn.dataset.tab;
|
|
document.getElementById(`tab-${tabId}`).classList.remove('hidden');
|
|
|
|
// Load workouts data when switching to workouts tab
|
|
if (tabId === 'workouts' && (!workoutState.workouts || workoutState.workouts.length === 0)) {
|
|
loadWorkoutsData();
|
|
}
|
|
|
|
// Re-render charts for this tab if data exists (to fix canvas size issues on hidden tabs)
|
|
const dateStr = state.currentDate ? state.currentDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0];
|
|
const key = `${state.healthPeriod}_${dateStr}`;
|
|
|
|
if (state.healthCache[key]) {
|
|
renderHealthDashboard(state.healthCache[key], state.healthPeriod);
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// Google Fit Sync Button Removed
|
|
// Health Connect Sync Button Removed
|
|
// Google Token Check Removed
|
|
|
|
|
|
// Init Sources
|
|
// Sync timeframe buttons with initial state
|
|
timeframeBtns.forEach(btn => {
|
|
if (btn.dataset.period === state.healthPeriod) {
|
|
btn.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400');
|
|
btn.classList.remove('text-gray-500', 'dark:text-gray-400');
|
|
} else {
|
|
btn.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400');
|
|
btn.classList.add('text-gray-500', 'dark:text-gray-400');
|
|
}
|
|
});
|
|
|
|
fetchDataSources();
|
|
}
|
|
|
|
async function fetchDataSources() {
|
|
try {
|
|
const res = await fetch(`${API_URL}/analytics/sources`);
|
|
if (!res.ok) return;
|
|
|
|
state.availableSources = await res.json();
|
|
const selector = document.getElementById('health-source-select');
|
|
const container = document.getElementById('source-selector-container');
|
|
const labelContainer = document.getElementById('source-label-container');
|
|
const nameDisplay = document.getElementById('source-name-display');
|
|
|
|
// Reset
|
|
selector.innerHTML = '<option value="">Auto (Best)</option>';
|
|
|
|
if (state.availableSources.length === 0) {
|
|
container.classList.add('hidden');
|
|
labelContainer.classList.add('hidden');
|
|
} else if (state.availableSources.length === 1) {
|
|
// One source: Show Label
|
|
const src = state.availableSources[0];
|
|
state.currentSource = src.package; // Auto-select
|
|
nameDisplay.textContent = src.name;
|
|
container.classList.add('hidden');
|
|
labelContainer.classList.remove('hidden');
|
|
} else {
|
|
// Multiple: Show Selector
|
|
state.availableSources.forEach(src => {
|
|
const opt = document.createElement('option');
|
|
opt.value = src.package;
|
|
opt.textContent = src.name;
|
|
selector.appendChild(opt);
|
|
});
|
|
container.classList.remove('hidden');
|
|
labelContainer.classList.add('hidden');
|
|
|
|
// Listener
|
|
selector.addEventListener('change', (e) => {
|
|
state.currentSource = e.target.value;
|
|
loadHealthData(state.healthPeriod);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load sources", e);
|
|
}
|
|
}
|
|
|
|
// performSync removed
|
|
|
|
|
|
async function loadHealthData(period) {
|
|
const dateStr = state.currentDate.toISOString().split('T')[0];
|
|
const key = `${period}_${dateStr}_${state.currentSource || 'all'}`;
|
|
|
|
try {
|
|
let data;
|
|
let mappedData = [];
|
|
const sourceParam = state.currentSource ? `&source=${state.currentSource}` : '';
|
|
|
|
if (period === 'day') {
|
|
// Fetch single day
|
|
const res = await fetch(`${API_URL}/analytics/daily?date_str=${dateStr}${sourceParam}`);
|
|
if (!res.ok) throw new Error("Failed to fetch daily stats");
|
|
const dayStats = await res.json();
|
|
|
|
// Map to expected format (array of 1)
|
|
mappedData = [{
|
|
date: dateStr,
|
|
step_count: dayStats.steps,
|
|
calories_burned: dayStats.calories,
|
|
sleep_duration_minutes: dayStats.sleep_minutes,
|
|
pai_score: (dayStats.steps / 100), // Mock PAI from steps
|
|
|
|
// Sleep Stages
|
|
deep_sleep_minutes: dayStats.deep_sleep_minutes,
|
|
light_sleep_minutes: dayStats.light_sleep_minutes,
|
|
rem_sleep_minutes: dayStats.rem_sleep_minutes,
|
|
awake_duration_minutes: dayStats.awake_duration_minutes,
|
|
|
|
// Heart Rate
|
|
resting_heart_rate: dayStats.resting_hr,
|
|
avg_heart_rate: dayStats.avg_heart_rate,
|
|
hrv: dayStats.hrv,
|
|
sleep_heart_rate: dayStats.sleep_avg_hr,
|
|
min_heart_rate: dayStats.min_heart_rate,
|
|
max_heart_rate: dayStats.max_heart_rate,
|
|
|
|
// Other
|
|
weight_kg: dayStats.weight_kg,
|
|
distance_meters: dayStats.distance_meters,
|
|
bedtime: dayStats.bedtime,
|
|
wake_time: dayStats.wake_time,
|
|
|
|
// Readiness & Context
|
|
readiness: dayStats.readiness,
|
|
user_context: dayStats.user_context
|
|
}];
|
|
|
|
// Day View specific cards are now handled by renderHealthDashboard via mappedData
|
|
} else {
|
|
// Fetch range
|
|
let start, end;
|
|
const d = new Date(state.currentDate);
|
|
if (period === 'week') {
|
|
end = new Date(d);
|
|
start = new Date(d);
|
|
start.setDate(start.getDate() - 6);
|
|
} else {
|
|
end = new Date(d);
|
|
start = new Date(d);
|
|
start.setDate(start.getDate() - 29);
|
|
}
|
|
|
|
const sStr = start.toISOString().split('T')[0];
|
|
const eStr = end.toISOString().split('T')[0];
|
|
|
|
const res = await fetch(`${API_URL}/analytics/range?start_date=${sStr}&end_date=${eStr}${sourceParam}`);
|
|
if (!res.ok) throw new Error("Failed to fetch range stats");
|
|
const rangeData = await res.json();
|
|
|
|
// Map range data
|
|
mappedData = rangeData.daily.map(day => ({
|
|
date: day.date,
|
|
step_count: day.steps,
|
|
calories_burned: day.calories, // Range endpoint needs to return this, updated analytics.py
|
|
sleep_duration_minutes: day.sleep_minutes,
|
|
pai_score: (day.steps / 100),
|
|
distance_meters: day.distance_meters,
|
|
// Add defaults for missing detailed stats in range view
|
|
deep_sleep_minutes: 0,
|
|
light_sleep_minutes: 0,
|
|
rem_sleep_minutes: 0,
|
|
awake_duration_minutes: 0,
|
|
resting_heart_rate: day.resting_hr || 0,
|
|
hrv: day.hrv || 0,
|
|
avg_heart_rate: 0,
|
|
min_heart_rate: 0,
|
|
max_heart_rate: 0,
|
|
weight_kg: 0,
|
|
weight_kg: 0,
|
|
bedtime: day.bedtime,
|
|
wake_time: day.wake_time,
|
|
|
|
// Enriched Range Data
|
|
readiness: { score: day.readiness, physical_score: 0, mental_score: day.recovery_score }, // Simplified mapping
|
|
strain: day.strain,
|
|
recovery_score: day.recovery_score,
|
|
mood_score: day.mood_score
|
|
}));
|
|
|
|
// Inside else block, we have sStr and eStr
|
|
await loadWorkoutsData(sStr, eStr);
|
|
}
|
|
|
|
// Load workouts for Day period here (Range handled above)
|
|
if (period === 'day') {
|
|
await loadWorkoutsData(dateStr, dateStr);
|
|
}
|
|
|
|
renderHealthDashboard(mappedData, period);
|
|
renderReadinessView(mappedData, period);
|
|
|
|
} catch (err) {
|
|
console.error("Error loading health data", err);
|
|
showToast("Failed to load health metrics", "error");
|
|
}
|
|
}
|
|
|
|
// --- NEW READINESS VIEW RENDERING ---
|
|
function renderReadinessView(data, period) {
|
|
if (!data || data.length === 0) return;
|
|
|
|
// --- DAY VIEW ---
|
|
if (period === 'day') {
|
|
const today = data[0];
|
|
const readiness = today.readiness || { score: 0, mental_score: 0, physical_score: 0, recommendation: "No data" };
|
|
const context = today.user_context || { mood_score: 50, tags: [] };
|
|
|
|
// 1. Update Gauge (Split)
|
|
drawSplitGauge(
|
|
'gauge-readiness-split',
|
|
readiness.physical_score || 0,
|
|
readiness.mental_score || 0
|
|
);
|
|
|
|
// 2. Update Strings
|
|
const scoreEl = document.getElementById('readiness-score');
|
|
if (scoreEl) scoreEl.innerText = readiness.score || '--';
|
|
const textEl = document.getElementById('readiness-text');
|
|
if (textEl) textEl.innerHTML = readiness.recommendation || 'No data available.';
|
|
|
|
// 3. Update Inputs
|
|
const slider = document.getElementById('readiness-slider');
|
|
const display = document.getElementById('slider-val-display');
|
|
const tagsInput = document.getElementById('context-tags-input');
|
|
|
|
if (slider) {
|
|
slider.value = context.mood_score || 50;
|
|
if (display) display.innerText = slider.value + '%';
|
|
|
|
// Local listener for display update
|
|
slider.oninput = (e) => {
|
|
if (display) display.innerText = e.target.value + '%';
|
|
};
|
|
}
|
|
if (tagsInput && context.tags) {
|
|
tagsInput.value = context.tags.join(', ');
|
|
}
|
|
}
|
|
|
|
// --- WEEK VIEW ---
|
|
else if (period === 'week') {
|
|
const labels = data.map(d => new Date(d.date).toLocaleDateString('en-US', { weekday: 'short' }));
|
|
const strainData = data.map(d => d.strain || 0);
|
|
const recoveryData = data.map(d => d.recovery_score || 0);
|
|
const hrvData = data.map(d => d.hrv || 0);
|
|
|
|
const ctx = document.getElementById('chart-recovery-strain').getContext('2d');
|
|
if (state.charts.readinessWeek) state.charts.readinessWeek.destroy();
|
|
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const textColor = isDark ? '#9ca3af' : '#4b5563';
|
|
|
|
state.charts.readinessWeek = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'Strain',
|
|
data: strainData,
|
|
backgroundColor: '#ef4444',
|
|
barPercentage: 0.6,
|
|
categoryPercentage: 0.8
|
|
},
|
|
{
|
|
label: 'Recovery',
|
|
data: recoveryData,
|
|
backgroundColor: '#22c55e',
|
|
barPercentage: 0.6,
|
|
categoryPercentage: 0.8
|
|
},
|
|
{
|
|
label: 'HRV Trend',
|
|
data: hrvData,
|
|
type: 'line',
|
|
borderColor: '#8b5cf6',
|
|
borderWidth: 2,
|
|
tension: 0.4,
|
|
yAxisID: 'y1',
|
|
pointRadius: 0
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { ticks: { color: textColor }, grid: { display: false } },
|
|
y: {
|
|
beginAtZero: true,
|
|
max: 100,
|
|
ticks: { color: textColor },
|
|
grid: { color: isDark ? '#374151' : '#e5e7eb' },
|
|
title: { display: true, text: 'Score' }
|
|
},
|
|
y1: {
|
|
type: 'linear',
|
|
position: 'right',
|
|
display: false // Hide right axis scale to keep it clean, just show trend
|
|
}
|
|
},
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
|
|
// Update Stats
|
|
const avgRecovery = Math.round(recoveryData.reduce((a, b) => a + b, 0) / recoveryData.length) || 0;
|
|
const avgStrain = Math.round(strainData.reduce((a, b) => a + b, 0) / strainData.length) || 0;
|
|
const recEl = document.getElementById('week-avg-recovery');
|
|
const strEl = document.getElementById('week-avg-strain');
|
|
if (recEl) recEl.innerText = avgRecovery;
|
|
if (strEl) strEl.innerText = avgStrain;
|
|
|
|
// Simple Balance Logic
|
|
const diff = avgRecovery - avgStrain;
|
|
const balanceEl = document.getElementById('week-balance-status');
|
|
if (balanceEl) {
|
|
if (diff < -20) { balanceEl.innerText = "Overload"; balanceEl.classList.add('text-red-500'); }
|
|
else if (diff > 20) { balanceEl.innerText = "Undertraining"; balanceEl.classList.add('text-blue-500'); }
|
|
else { balanceEl.innerText = "Optimal"; balanceEl.classList.add('text-green-500'); }
|
|
}
|
|
}
|
|
|
|
// --- MONTH VIEW ---
|
|
else if (period === 'month') {
|
|
const heatmapContainer = document.getElementById('heatmap-container');
|
|
if (heatmapContainer) {
|
|
heatmapContainer.innerHTML = '';
|
|
// Heatmap of Readiness
|
|
data.forEach(d => {
|
|
const dayDiv = document.createElement('div');
|
|
const readiness = d.recovery_score || d.readiness?.score || 0;
|
|
let colorClass = 'bg-gray-200 dark:bg-gray-700';
|
|
|
|
// Color Scale
|
|
if (readiness > 85) colorClass = 'bg-green-500';
|
|
else if (readiness > 70) colorClass = 'bg-green-400';
|
|
else if (readiness > 50) colorClass = 'bg-yellow-400';
|
|
else if (readiness > 30) colorClass = 'bg-orange-400';
|
|
else if (readiness > 0) colorClass = 'bg-red-500';
|
|
|
|
dayDiv.className = `w-full h-8 rounded-sm ${colorClass} transition-opacity hover:opacity-80`;
|
|
dayDiv.title = `${d.date}: ${readiness}`;
|
|
heatmapContainer.appendChild(dayDiv);
|
|
});
|
|
}
|
|
|
|
// Baseline Graph
|
|
const ctxHrv = document.getElementById('chart-hrv-trend').getContext('2d');
|
|
if (state.charts.readinessMonth) state.charts.readinessMonth.destroy();
|
|
|
|
const labels = data.map(d => d.date.split('-')[2]); // Just day
|
|
const hrvData = data.map(d => d.hrv || 0);
|
|
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const textColor = isDark ? '#9ca3af' : '#4b5563';
|
|
|
|
state.charts.readinessMonth = new Chart(ctxHrv, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Daily HRV',
|
|
data: hrvData,
|
|
borderColor: '#8b5cf6',
|
|
backgroundColor: '#8b5cf610',
|
|
fill: true,
|
|
tension: 0.4,
|
|
pointRadius: 2
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { ticks: { color: textColor, maxTicksLimit: 10 }, grid: { display: false } },
|
|
y: { ticks: { color: textColor }, grid: { color: isDark ? '#374151' : '#e5e7eb' } }
|
|
},
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
|
|
// Calculate Month Stats
|
|
const avgHrv = Math.round(hrvData.reduce((a, b) => a + b, 0) / hrvData.length) || 0;
|
|
const hrvAvgEl = document.getElementById('month-hrv-avg');
|
|
if (hrvAvgEl) hrvAvgEl.innerText = avgHrv + ' ms';
|
|
}
|
|
}
|
|
|
|
function drawSplitGauge(canvasId, physical, mental) {
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Resize handling using clientWidth/Height for HDPI
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = canvas.getBoundingClientRect();
|
|
canvas.width = rect.width * dpr;
|
|
canvas.height = rect.height * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
const w = rect.width;
|
|
const h = rect.height;
|
|
if (w === 0 || h === 0) return; // Canvas not ready or hidden
|
|
|
|
const cx = w / 2;
|
|
const cy = h / 2;
|
|
const r = Math.min(w, h) / 2 - 20;
|
|
const lineWidth = 15;
|
|
|
|
if (r <= 5) return; // Too small to draw meaningfully
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.lineCap = 'round';
|
|
|
|
// Helper to draw arc
|
|
function drawArc(startAngle, endAngle, color, radius) {
|
|
if (radius <= 0) return;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius, startAngle, endAngle);
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Background Rings
|
|
drawArc(Math.PI * 0.8, Math.PI * 2.2, '#e5e7eb', r); // Outer bg (Physical)
|
|
drawArc(Math.PI * 0.8, Math.PI * 2.2, '#e5e7eb', r - 20); // Inner bg (Mental)
|
|
|
|
// Physical (Outer)
|
|
// Map 0-100 to angle range
|
|
const start = Math.PI * 0.8;
|
|
const totalAngle = Math.PI * 1.4; // 2.2 - 0.8
|
|
const physEnd = start + (totalAngle * (physical / 100));
|
|
|
|
// Color logic
|
|
const physColor = physical > 80 ? '#22c55e' : (physical > 50 ? '#eab308' : '#ef4444');
|
|
drawArc(start, physEnd, physColor, r);
|
|
|
|
// Mental (Inner)
|
|
const mentEnd = start + (totalAngle * (mental / 100));
|
|
const mentColor = mental > 80 ? '#3b82f6' : (mental > 50 ? '#a855f7' : '#ef4444');
|
|
drawArc(start, mentEnd, mentColor, r - 20);
|
|
}
|
|
|
|
function changeDate(delta) {
|
|
if (!state.currentDate) state.currentDate = new Date();
|
|
|
|
// Adjust based on period
|
|
if (state.healthPeriod === 'day') {
|
|
state.currentDate.setDate(state.currentDate.getDate() + delta);
|
|
} else if (state.healthPeriod === 'week') {
|
|
state.currentDate.setDate(state.currentDate.getDate() + (delta * 7));
|
|
} else if (state.healthPeriod === 'month') {
|
|
state.currentDate.setMonth(state.currentDate.getMonth() + delta);
|
|
}
|
|
|
|
updateDateDisplay();
|
|
loadHealthData(state.healthPeriod);
|
|
|
|
// Update workout views with new date
|
|
switchPeriodViews(state.healthPeriod);
|
|
}
|
|
|
|
function updateDateDisplay() {
|
|
const disp = document.getElementById('date-display-text');
|
|
if (!disp) return;
|
|
|
|
const dt = state.currentDate;
|
|
const optionsShort = { month: 'short', day: 'numeric' };
|
|
const optionsYear = { month: 'long', year: 'numeric' };
|
|
|
|
let text = "";
|
|
|
|
// Check if today
|
|
const today = new Date();
|
|
const isToday = dt.toDateString() === today.toDateString();
|
|
|
|
if (state.healthPeriod === 'day') {
|
|
if (isToday) text = "Today";
|
|
else text = dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
|
} else if (state.healthPeriod === 'week') {
|
|
// Week ending on dt
|
|
const start = new Date(dt);
|
|
start.setDate(dt.getDate() - 6);
|
|
|
|
// Format: "Dec 22 - Dec 28"
|
|
const sStr = start.toLocaleDateString(undefined, optionsShort);
|
|
const eStr = dt.toLocaleDateString(undefined, optionsShort);
|
|
text = `${sStr} - ${eStr}`;
|
|
} else if (state.healthPeriod === 'month') {
|
|
// Just Month Year? Or "Last 30 Days"?
|
|
// Backend logic is "Last 30 Days" ending on Date.
|
|
// It's cleaner to show "Ending Dec 29" for rolling window, or just the date range.
|
|
// Given typically users expect "Month" to be calendar month, but we implemented rolling.
|
|
// Let's show range for clarity.
|
|
const start = new Date(dt);
|
|
start.setDate(dt.getDate() - 29);
|
|
const sStr = start.toLocaleDateString(undefined, optionsShort);
|
|
const eStr = dt.toLocaleDateString(undefined, optionsShort);
|
|
text = `${sStr} - ${eStr}`;
|
|
}
|
|
|
|
disp.innerText = text;
|
|
}
|
|
|
|
// ===== DATE PICKER MODAL =====
|
|
function initDatePickerModal() {
|
|
const modal = document.getElementById('date-picker-modal');
|
|
const dateDisplayBtn = document.getElementById('date-display');
|
|
const closeBtn = document.getElementById('close-date-picker-btn');
|
|
const backdrop = document.getElementById('date-picker-backdrop');
|
|
|
|
// Single date elements
|
|
const customDateInput = document.getElementById('custom-date-input');
|
|
const singleDateView = document.getElementById('single-date-view');
|
|
const singleDateToggle = document.getElementById('single-date-toggle');
|
|
|
|
// Date range elements
|
|
const dateRangeView = document.getElementById('date-range-view');
|
|
const dateRangeToggle = document.getElementById('date-range-toggle');
|
|
const customStartDate = document.getElementById('custom-start-date');
|
|
const customEndDate = document.getElementById('custom-end-date');
|
|
const periodInfo = document.getElementById('period-info');
|
|
const periodInfoText = document.getElementById('period-info-text');
|
|
|
|
const applyCustomBtn = document.getElementById('apply-custom-date-btn');
|
|
const applyBtnText = document.getElementById('apply-btn-text');
|
|
const presetBtns = document.querySelectorAll('.preset-btn');
|
|
|
|
if (!modal || !dateDisplayBtn) return;
|
|
|
|
let isRangeMode = false;
|
|
|
|
// Open modal
|
|
dateDisplayBtn.addEventListener('click', () => {
|
|
toggleModal(modal, true);
|
|
// Set custom date input to current date
|
|
if (customDateInput && state.currentDate) {
|
|
customDateInput.value = state.currentDate.toISOString().split('T')[0];
|
|
}
|
|
if (customStartDate && customEndDate && state.currentDate) {
|
|
const endDate = state.currentDate.toISOString().split('T')[0];
|
|
customEndDate.value = endDate;
|
|
|
|
// Set start date based on current period
|
|
const start = new Date(state.currentDate);
|
|
if (state.healthPeriod === 'week') {
|
|
start.setDate(start.getDate() - 6);
|
|
} else if (state.healthPeriod === 'month') {
|
|
start.setDate(start.getDate() - 29);
|
|
} else {
|
|
start.setDate(start.getDate() - 6); // Default to week
|
|
}
|
|
customStartDate.value = start.toISOString().split('T')[0];
|
|
}
|
|
});
|
|
|
|
// Close modal
|
|
const closeModal = () => toggleModal(modal, false);
|
|
if (closeBtn) closeBtn.addEventListener('click', closeModal);
|
|
if (backdrop) backdrop.addEventListener('click', closeModal);
|
|
|
|
// Toggle between single date and date range
|
|
if (singleDateToggle) {
|
|
singleDateToggle.addEventListener('click', () => {
|
|
isRangeMode = false;
|
|
singleDateView.classList.remove('hidden');
|
|
dateRangeView.classList.add('hidden');
|
|
|
|
// Update toggle buttons
|
|
singleDateToggle.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400');
|
|
singleDateToggle.classList.remove('text-gray-500', 'dark:text-gray-400');
|
|
dateRangeToggle.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400');
|
|
dateRangeToggle.classList.add('text-gray-500', 'dark:text-gray-400');
|
|
|
|
// Update button text
|
|
applyBtnText.textContent = 'Apply Custom Date';
|
|
});
|
|
}
|
|
|
|
if (dateRangeToggle) {
|
|
dateRangeToggle.addEventListener('click', () => {
|
|
isRangeMode = true;
|
|
singleDateView.classList.add('hidden');
|
|
dateRangeView.classList.remove('hidden');
|
|
|
|
// Update toggle buttons
|
|
dateRangeToggle.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400');
|
|
dateRangeToggle.classList.remove('text-gray-500', 'dark:text-gray-400');
|
|
singleDateToggle.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400');
|
|
singleDateToggle.classList.add('text-gray-500', 'dark:text-gray-400');
|
|
|
|
// Update button text
|
|
applyBtnText.textContent = 'Apply Date Range';
|
|
|
|
// Update period info if dates are set
|
|
updatePeriodInfo();
|
|
});
|
|
}
|
|
|
|
// Update period info when dates change
|
|
if (customStartDate) {
|
|
customStartDate.addEventListener('change', updatePeriodInfo);
|
|
}
|
|
if (customEndDate) {
|
|
customEndDate.addEventListener('change', updatePeriodInfo);
|
|
}
|
|
|
|
function updatePeriodInfo() {
|
|
if (!customStartDate.value || !customEndDate.value) {
|
|
periodInfo.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
const start = new Date(customStartDate.value);
|
|
const end = new Date(customEndDate.value);
|
|
|
|
// Validate dates
|
|
if (end < start) {
|
|
periodInfoText.textContent = '⚠️ End date must be after start date';
|
|
periodInfo.classList.remove('hidden');
|
|
periodInfo.classList.remove('bg-brand-50', 'dark:bg-brand-900/20', 'border-brand-200', 'dark:border-brand-800');
|
|
periodInfo.classList.add('bg-red-50', 'dark:bg-red-900/20', 'border-red-200', 'dark:border-red-800');
|
|
return;
|
|
}
|
|
|
|
// Calculate days
|
|
const diffTime = Math.abs(end - start);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; // +1 to include both start and end
|
|
|
|
// Determine view mode
|
|
let viewMode = '';
|
|
let icon = '';
|
|
if (diffDays === 1) {
|
|
viewMode = 'Day View';
|
|
icon = '📅';
|
|
} else if (diffDays <= 7) {
|
|
viewMode = 'Week View';
|
|
icon = '📊';
|
|
} else {
|
|
viewMode = 'Month View';
|
|
icon = '📈';
|
|
}
|
|
|
|
periodInfoText.textContent = `${icon} ${diffDays} day${diffDays > 1 ? 's' : ''} selected → ${viewMode}`;
|
|
periodInfo.classList.remove('hidden', 'bg-red-50', 'dark:bg-red-900/20', 'border-red-200', 'dark:border-red-800');
|
|
periodInfo.classList.add('bg-brand-50', 'dark:bg-brand-900/20', 'border-brand-200', 'dark:border-brand-800');
|
|
}
|
|
|
|
// Preset buttons
|
|
presetBtns.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const preset = btn.dataset.preset;
|
|
applyPreset(preset);
|
|
closeModal();
|
|
});
|
|
});
|
|
|
|
// Custom date/range apply
|
|
if (applyCustomBtn) {
|
|
applyCustomBtn.addEventListener('click', () => {
|
|
if (isRangeMode) {
|
|
// Date range mode
|
|
if (!customStartDate.value || !customEndDate.value) {
|
|
showToast('Please select both start and end dates', 'error');
|
|
return;
|
|
}
|
|
|
|
const start = new Date(customStartDate.value + 'T12:00:00');
|
|
const end = new Date(customEndDate.value + 'T12:00:00');
|
|
|
|
if (end < start) {
|
|
showToast('End date must be after start date', 'error');
|
|
return;
|
|
}
|
|
|
|
// Calculate period length
|
|
const diffTime = Math.abs(end - start);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
|
|
|
// Set the end date as current date
|
|
state.currentDate = end;
|
|
|
|
// Determine and set period based on days
|
|
if (diffDays === 1) {
|
|
state.healthPeriod = 'day';
|
|
} else if (diffDays <= 7) {
|
|
state.healthPeriod = 'week';
|
|
} else {
|
|
state.healthPeriod = 'month';
|
|
}
|
|
|
|
updateTimeframeButtons(state.healthPeriod);
|
|
updateDateDisplay();
|
|
loadHealthData(state.healthPeriod);
|
|
switchPeriodViews(state.healthPeriod);
|
|
closeModal();
|
|
|
|
showToast(`Viewing ${diffDays} day${diffDays > 1 ? 's' : ''} of data`, 'success');
|
|
} else {
|
|
// Single date mode
|
|
const selectedDate = customDateInput.value;
|
|
if (selectedDate) {
|
|
state.currentDate = new Date(selectedDate + 'T12:00:00');
|
|
state.healthPeriod = 'day';
|
|
updateTimeframeButtons('day');
|
|
updateDateDisplay();
|
|
loadHealthData(state.healthPeriod);
|
|
switchPeriodViews(state.healthPeriod);
|
|
closeModal();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
function applyPreset(preset) {
|
|
const today = new Date();
|
|
let targetDate = new Date();
|
|
|
|
switch (preset) {
|
|
case 'today':
|
|
targetDate = today;
|
|
break;
|
|
case 'yesterday':
|
|
targetDate.setDate(today.getDate() - 1);
|
|
break;
|
|
case 'this-week':
|
|
// Set to end of current week (Sunday or today if before Sunday)
|
|
const dayOfWeek = today.getDay();
|
|
const daysUntilSunday = dayOfWeek === 0 ? 0 : 7 - dayOfWeek;
|
|
targetDate.setDate(today.getDate() + daysUntilSunday);
|
|
// Switch to week view
|
|
state.healthPeriod = 'week';
|
|
updateTimeframeButtons('week');
|
|
break;
|
|
case 'last-week':
|
|
// Set to last Sunday
|
|
const lastSunday = new Date(today);
|
|
lastSunday.setDate(today.getDate() - today.getDay());
|
|
targetDate = lastSunday;
|
|
// Switch to week view
|
|
state.healthPeriod = 'week';
|
|
updateTimeframeButtons('week');
|
|
break;
|
|
case 'this-month':
|
|
// Set to end of current month
|
|
targetDate = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
|
// Switch to month view
|
|
state.healthPeriod = 'month';
|
|
updateTimeframeButtons('month');
|
|
break;
|
|
case 'last-30-days':
|
|
targetDate = today;
|
|
// Switch to month view
|
|
state.healthPeriod = 'month';
|
|
updateTimeframeButtons('month');
|
|
break;
|
|
}
|
|
|
|
state.currentDate = targetDate;
|
|
updateDateDisplay();
|
|
loadHealthData(state.healthPeriod);
|
|
switchPeriodViews(state.healthPeriod);
|
|
}
|
|
|
|
function updateTimeframeButtons(period) {
|
|
const timeframeBtns = document.querySelectorAll('.timeframe-btn');
|
|
timeframeBtns.forEach(btn => {
|
|
btn.classList.remove('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400');
|
|
btn.classList.add('text-gray-500', 'dark:text-gray-400');
|
|
|
|
if (btn.dataset.period === period) {
|
|
btn.classList.add('bg-white', 'dark:bg-gray-700', 'shadow-sm', 'text-brand-600', 'dark:text-brand-400');
|
|
btn.classList.remove('text-gray-500', 'dark:text-gray-400');
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
function renderHealthDashboard(data, period) {
|
|
if (!data || data.length === 0) {
|
|
// Clear/Reset UI to avoid stale data
|
|
const safeSetText = (id, val) => { const el = document.getElementById(id); if (el) el.innerText = val; };
|
|
const safeSetHtml = (id, val) => { const el = document.getElementById(id); if (el) el.innerHTML = val; };
|
|
|
|
safeSetText('ring-steps', '--');
|
|
safeSetText('ring-cals', '--');
|
|
safeSetText('val-pai-ring', '--');
|
|
safeSetHtml('overview-hr', `-- <span class="text-sm font-normal text-gray-400">bpm</span>`);
|
|
safeSetText('overview-sleep-dur', '--h --m');
|
|
safeSetText('overview-sleep-score', '--');
|
|
safeSetText('val-deep-overview', '--');
|
|
safeSetText('val-rem-overview', '--');
|
|
safeSetText('val-light-overview', '--');
|
|
['sleep-bar-deep', 'sleep-bar-rem', 'sleep-bar-light'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.style.width = '0%';
|
|
});
|
|
safeSetText('val-weight', '-- kg');
|
|
safeSetText('val-weight-gauge', '--');
|
|
safeSetText('val-bmi', '--');
|
|
safeSetText('val-bmi-gauge', '--');
|
|
safeSetText('readiness-score', '--');
|
|
safeSetText('val-rhr', '--');
|
|
safeSetText('val-hrv', '--');
|
|
|
|
// Clear Charts
|
|
if (state.charts.health.rings) { state.charts.health.rings.destroy(); state.charts.health.rings = null; }
|
|
if (state.charts.health.hrMini) { state.charts.health.hrMini.destroy(); state.charts.health.hrMini = null; }
|
|
|
|
return;
|
|
}
|
|
|
|
// --- Calcs ---
|
|
const latest = data[data.length - 1]; // Latest day
|
|
let avgHr = 0, avgSteps = 0, avgSleep = 0, avgPai = 0, avgRhr = 0, avgHrv = 0;
|
|
|
|
// Averages over the period
|
|
const count = data.length;
|
|
data.forEach(d => {
|
|
avgHr += d.avg_heart_rate || 0;
|
|
avgSteps += d.step_count || 0;
|
|
avgSleep += d.sleep_duration_minutes || 0;
|
|
avgPai += d.pai_score || 0;
|
|
avgRhr += d.resting_heart_rate || 0;
|
|
avgHrv += d.hrv || 0;
|
|
});
|
|
|
|
avgHr = Math.round(avgHr / count);
|
|
avgSteps = Math.round(avgSteps / count);
|
|
avgSleep = Math.round(avgSleep / count);
|
|
avgPai = (avgPai / count).toFixed(1);
|
|
avgRhr = Math.round(avgRhr / count);
|
|
avgHrv = Math.round(avgHrv / count);
|
|
|
|
// --- Tab: Overview ---
|
|
// Rings
|
|
renderRingsChart(avgSteps, avgPai, 500); // 500 active cals goal placeholder
|
|
document.getElementById('ring-steps').innerText = latest.step_count?.toLocaleString() || '--';
|
|
document.getElementById('ring-cals').innerText = Math.round(latest.calories_burned || 0);
|
|
document.getElementById('val-pai-ring').innerText = Math.round(latest.pai_score || 0);
|
|
|
|
// Mini HR Chart
|
|
document.getElementById('overview-hr').innerHTML = `${latest.avg_heart_rate || '--'} <span class="text-sm font-normal text-gray-400">bpm</span>`;
|
|
renderMiniHrChart(data);
|
|
|
|
// Sleep Summary
|
|
const sh = Math.floor((latest.sleep_duration_minutes || 0) / 60);
|
|
const sm = (latest.sleep_duration_minutes || 0) % 60;
|
|
document.getElementById('overview-sleep-dur').innerText = `${sh}h ${sm}m`;
|
|
document.getElementById('overview-sleep-score').innerText = calculateSleepScore(latest);
|
|
|
|
// Sleep Stages
|
|
const deepMin = latest.deep_sleep_minutes || 0;
|
|
const remMin = latest.rem_sleep_minutes || 0;
|
|
const lightMin = latest.light_sleep_minutes || 0;
|
|
|
|
const fmtTime = (min) => {
|
|
const h = Math.floor(min / 60);
|
|
const m = min % 60;
|
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
};
|
|
|
|
const safeSetText = (id, val) => { const el = document.getElementById(id); if (el) el.innerText = val; };
|
|
safeSetText('val-deep-overview', fmtTime(deepMin));
|
|
safeSetText('val-rem-overview', fmtTime(remMin));
|
|
safeSetText('val-light-overview', fmtTime(lightMin));
|
|
|
|
// Stacked Bar (Goal: 8 hours)
|
|
const sleepGoal = 8 * 60;
|
|
const deepPct = Math.min(100, (deepMin / sleepGoal) * 100);
|
|
const remPct = Math.min(100, (remMin / sleepGoal) * 100);
|
|
const lightPct = Math.min(100, (lightMin / sleepGoal) * 100);
|
|
|
|
const setWidth = (id, pct) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.style.width = `${pct}%`;
|
|
};
|
|
setWidth('sleep-bar-deep', deepPct);
|
|
setWidth('sleep-bar-rem', remPct);
|
|
setWidth('sleep-bar-light', lightPct);
|
|
|
|
// Metrics Row - Update both old and new gauge widgets
|
|
// IMPORTANT: Search for latest valid weight in ALL data, not just latest entry
|
|
const heightM = 1.75; // Default height for BMI calculation
|
|
|
|
// Process all data to add normalized weight and BMI
|
|
const dataWithBMI = data.map(d => {
|
|
const w = d.weight || d.weight_kg;
|
|
return {
|
|
...d,
|
|
weight_norm: w,
|
|
bmi: w ? (w / (heightM * heightM)) : null
|
|
};
|
|
});
|
|
|
|
// Find the LATEST entry that has a valid weight (searching backwards)
|
|
const reversedData = [...dataWithBMI].reverse();
|
|
const latestWithWeight = reversedData.find(d => d.weight_norm && d.weight_norm > 0);
|
|
|
|
if (latestWithWeight) {
|
|
const weightVal = latestWithWeight.weight_norm;
|
|
const bmi = latestWithWeight.bmi;
|
|
|
|
// Update text displays
|
|
const weightEl = document.getElementById('val-weight');
|
|
const bmiEl = document.getElementById('val-bmi');
|
|
if (weightEl) weightEl.innerText = `${weightVal.toFixed(1)} kg`;
|
|
if (bmiEl) bmiEl.innerText = bmi.toFixed(1);
|
|
|
|
// Initialize gauges with the latest valid data
|
|
initializeMetricGauges(dataWithBMI, latestWithWeight);
|
|
} else {
|
|
// No weight data found at all
|
|
const weightEl = document.getElementById('val-weight');
|
|
const bmiEl = document.getElementById('val-bmi');
|
|
if (weightEl) weightEl.innerText = '-- kg';
|
|
if (bmiEl) bmiEl.innerText = '--';
|
|
|
|
// Reset gauges display
|
|
const weightGaugeEl = document.getElementById('val-weight-gauge');
|
|
const bmiGaugeEl = document.getElementById('val-bmi-gauge');
|
|
if (weightGaugeEl) weightGaugeEl.innerText = '--';
|
|
if (bmiGaugeEl) bmiGaugeEl.innerText = '--';
|
|
}
|
|
|
|
// --- Tab: Readiness ---
|
|
updateReadinessUI(latest, data);
|
|
|
|
renderRhrChart(data);
|
|
renderHrvChart(data);
|
|
|
|
|
|
// --- Tab: Sleep ---
|
|
// Total Sleep Duration
|
|
const total = latest.sleep_duration_minutes || 0;
|
|
const totalH = Math.floor(total / 60);
|
|
const totalM = total % 60;
|
|
document.getElementById('sleep-day-total').innerText = `${totalH}h ${totalM}m`;
|
|
|
|
// Deep/Light/REM
|
|
const dH = Math.floor((latest.deep_sleep_minutes || 0) / 60);
|
|
const dM = (latest.deep_sleep_minutes || 0) % 60;
|
|
document.getElementById('val-deep').innerText = `${dH}h ${dM}m`;
|
|
|
|
// We don't have REM/Light separated in Zepp import perfectly yet?
|
|
// We have deep and awake. Total - Deep - Awake = Light+REM.
|
|
const awake = latest.awake_duration_minutes || 0;
|
|
document.getElementById('val-light').innerText = `${Math.floor((latest.light_sleep_minutes || 0) / 60)}h ${(latest.light_sleep_minutes || 0) % 60}m`;
|
|
document.getElementById('val-rem').innerText = `${Math.floor((latest.rem_sleep_minutes || 0) / 60)}h ${(latest.rem_sleep_minutes || 0) % 60}m`;
|
|
document.getElementById('val-awake').innerText = `${latest.awake_duration_minutes || 0}m`;
|
|
|
|
document.getElementById('val-sleep-hr').innerText = latest.sleep_heart_rate || '--';
|
|
|
|
// Sleep Quality & Efficiency
|
|
const sleepScore = calculateSleepScore(latest);
|
|
const scoreEl = document.getElementById('sleep-quality-score');
|
|
if (scoreEl) scoreEl.innerText = sleepScore;
|
|
|
|
const efficiency = total + awake > 0 ? Math.round((total / (total + awake)) * 100) : '--';
|
|
const cellEff = document.getElementById('sleep-efficiency');
|
|
if (cellEff) cellEff.innerText = `${efficiency}%`;
|
|
|
|
// Bedtime / Wake
|
|
if (latest.bedtime) {
|
|
const bt = new Date(latest.bedtime);
|
|
document.getElementById('sleep-start-time').innerText = `Bedtime: ${bt.getHours()}:${bt.getMinutes().toString().padStart(2, '0')}`;
|
|
} else {
|
|
document.getElementById('sleep-start-time').innerText = `Bedtime: --:--`;
|
|
}
|
|
|
|
if (latest.wake_time) {
|
|
const wt = new Date(latest.wake_time);
|
|
document.getElementById('sleep-end-time').innerText = `Wake: ${wt.getHours()}:${wt.getMinutes().toString().padStart(2, '0')}`;
|
|
} else {
|
|
document.getElementById('sleep-end-time').innerText = `Wake: --:--`;
|
|
}
|
|
|
|
renderHypnogram(latest);
|
|
|
|
|
|
|
|
// --- Tab: Exertion ---
|
|
document.getElementById('val-exertion-pai').innerText = Math.round(latest.pai_score || 0);
|
|
renderExertionChart(data);
|
|
|
|
// --- Period-Specific Rendering ---
|
|
// Transform data for period views (expects daily array format)
|
|
const dailyData = data.map(d => ({
|
|
date: d.date,
|
|
steps: d.step_count || 0,
|
|
calories: d.calories_burned || 0,
|
|
pai: d.pai_score || 0,
|
|
sleep_duration: d.sleep_duration_minutes || 0,
|
|
resting_hr: d.resting_heart_rate || 0,
|
|
avg_hr: d.avg_heart_rate || 0,
|
|
hrv: d.hrv || 0,
|
|
deep_sleep: d.deep_sleep_minutes || 0,
|
|
light_sleep: d.light_sleep_minutes || 0,
|
|
rem_sleep: d.rem_sleep_minutes || 0,
|
|
awake: d.awake_duration_minutes || 0,
|
|
bedtime: d.bedtime,
|
|
wake_time: d.wake_time
|
|
}));
|
|
|
|
if (period === 'week' && dailyData.length > 0) {
|
|
renderWeekOverview({ daily: dailyData });
|
|
renderSleepWeek(dailyData);
|
|
} else if (period === 'month' && dailyData.length > 0) {
|
|
renderMonthOverview({ daily: dailyData });
|
|
renderSleepMonth(dailyData);
|
|
}
|
|
|
|
// Update HR min/max/avg for day view
|
|
if (period === 'day' && latest) {
|
|
const hrMinEl = document.getElementById('hr-min-day');
|
|
const hrAvgEl = document.getElementById('hr-avg-day');
|
|
const hrMaxEl = document.getElementById('hr-max-day');
|
|
if (hrMinEl) hrMinEl.innerText = latest.min_heart_rate || '--';
|
|
if (hrAvgEl) hrAvgEl.innerText = latest.avg_heart_rate || '--';
|
|
if (hrMaxEl) hrMaxEl.innerText = latest.max_heart_rate || '--';
|
|
|
|
// Update distance in ring
|
|
const distEl = document.getElementById('ring-distance');
|
|
if (distEl) distEl.innerText = `${((latest.distance_meters || 0) / 1000).toFixed(1)} km`;
|
|
}
|
|
|
|
// --- Tab: Workouts Update ---
|
|
if (period === 'month') {
|
|
renderWorkoutsMonth();
|
|
} else if (period === 'week') {
|
|
renderWorkoutsWeek();
|
|
} else {
|
|
renderWorkoutsDay();
|
|
}
|
|
}
|
|
|
|
function calculateSleepScore(data) {
|
|
if (!data.sleep_duration_minutes) return '--';
|
|
let score = 50; // Base score
|
|
|
|
// Duration (Goal: 7-9 hours)
|
|
const hours = data.sleep_duration_minutes / 60;
|
|
if (hours >= 7 && hours <= 9) score += 25;
|
|
else if (hours >= 6) score += 15;
|
|
else if (hours > 4) score += 5;
|
|
|
|
// Deep Sleep (Goal: ~20% of total)
|
|
const deepPct = (data.deep_sleep_minutes / data.sleep_duration_minutes) * 100;
|
|
if (deepPct >= 15 && deepPct <= 25) score += 15;
|
|
else if (deepPct > 10) score += 10;
|
|
|
|
// Consistency (Time of bed)
|
|
if (data.bedtime) {
|
|
const bt = new Date(data.bedtime);
|
|
const hour = bt.getHours();
|
|
// Bedtime between 10pm and 12am is usually "better" for circadian rhythm
|
|
if (hour >= 21 || hour <= 0) score += 10;
|
|
}
|
|
|
|
// Awake time penalty
|
|
if (data.awake_duration_minutes > 60) score -= 10;
|
|
else if (data.awake_duration_minutes > 30) score -= 5;
|
|
|
|
return Math.min(100, Math.max(0, Math.round(score)));
|
|
}
|
|
|
|
// --- Readiness Logic ---
|
|
function calculateReadiness(data) {
|
|
if (!data) return { score: 0, factors: [] };
|
|
|
|
let score = 70; // Base score
|
|
const factors = [];
|
|
|
|
// 1. Sleep Factor (Max +/- 20)
|
|
// Goal: 7-9 hours
|
|
const sleepHrs = (data.sleep_duration_minutes || 0) / 60;
|
|
if (sleepHrs >= 7) {
|
|
score += 15;
|
|
factors.push({ label: 'Good sleep duration (7h+)', type: 'good' });
|
|
} else if (sleepHrs >= 6) {
|
|
score += 5;
|
|
factors.push({ label: 'Moderate sleep duration', type: 'neutral' });
|
|
} else {
|
|
score -= 15;
|
|
factors.push({ label: 'Poor sleep duration (<6h)', type: 'bad' });
|
|
}
|
|
|
|
// 2. Resting HR Factor (Max +/- 15)
|
|
// Baseline assumption: 60bpm. Lower is better.
|
|
const rhr = data.resting_heart_rate;
|
|
if (rhr) {
|
|
if (rhr <= 60) {
|
|
score += 15;
|
|
factors.push({ label: 'Optimal resting heart rate', type: 'good' });
|
|
} else if (rhr <= 70) {
|
|
// Neutral
|
|
} else {
|
|
score -= 10;
|
|
factors.push({ label: 'Elevated resting heart rate', type: 'bad' });
|
|
}
|
|
}
|
|
|
|
// 3. HRV Factor (Max +/- 15)
|
|
// Baseline assumption: 40ms. Higher is better.
|
|
const hrv = data.hrv;
|
|
if (hrv) {
|
|
if (hrv >= 50) {
|
|
score += 15;
|
|
factors.push({ label: 'High HRV (Recovery)', type: 'good' });
|
|
} else if (hrv >= 30) {
|
|
// Neutral
|
|
} else {
|
|
score -= 10;
|
|
factors.push({ label: 'Low HRV (Stress)', type: 'bad' });
|
|
}
|
|
} else {
|
|
// No HRV data - neutral
|
|
factors.push({ label: 'No HRV data available', type: 'neutral' });
|
|
}
|
|
|
|
return {
|
|
score: Math.min(100, Math.max(0, score)),
|
|
factors
|
|
};
|
|
}
|
|
|
|
function updateReadinessUI(latest, allData) {
|
|
const { score, factors } = calculateReadiness(latest);
|
|
|
|
// 1. Update Score & Ring
|
|
document.getElementById('readiness-score').innerText = score;
|
|
const ring = document.getElementById('readiness-ring');
|
|
if (ring) {
|
|
// Circumference approx 440
|
|
const offset = 440 - (440 * (score / 100));
|
|
ring.style.strokeDashoffset = offset;
|
|
|
|
// Color based on score
|
|
ring.classList.remove('text-green-500', 'text-yellow-500', 'text-orange-500', 'text-red-500');
|
|
if (score >= 85) ring.classList.add('text-green-500');
|
|
else if (score >= 70) ring.classList.add('text-green-400');
|
|
else if (score >= 50) ring.classList.add('text-yellow-500');
|
|
else ring.classList.add('text-red-500');
|
|
}
|
|
|
|
// 2. Update Text
|
|
const textEl = document.getElementById('readiness-text');
|
|
if (textEl) {
|
|
if (score >= 85) {
|
|
textEl.innerHTML = `Your physical recovery is excellent. <span class="text-gray-900 dark:text-white font-bold">You are ready for high intensity training.</span>`;
|
|
} else if (score >= 70) {
|
|
textEl.innerHTML = `You are well recovered. <span class="text-gray-900 dark:text-white font-bold">Ready for normal training load.</span>`;
|
|
} else if (score >= 50) {
|
|
textEl.innerHTML = `Recovery is moderate. <span class="text-gray-900 dark:text-white font-bold">Consider a lighter training session.</span>`;
|
|
} else {
|
|
textEl.innerHTML = `Your body needs rest. <span class="text-gray-900 dark:text-white font-bold">Focus on active recovery today.</span>`;
|
|
}
|
|
}
|
|
|
|
// 3. Update Factors List
|
|
const factorsContainer = document.getElementById('readiness-factors');
|
|
if (factorsContainer) {
|
|
factorsContainer.innerHTML = factors.map(f => {
|
|
let icon, color, bg;
|
|
if (f.type === 'good') {
|
|
icon = 'fa-check-circle text-green-500';
|
|
bg = 'bg-green-50 dark:bg-green-900/20';
|
|
} else if (f.type === 'bad') {
|
|
icon = 'fa-exclamation-circle text-red-500';
|
|
bg = 'bg-red-50 dark:bg-red-900/20';
|
|
} else {
|
|
icon = 'fa-minus-circle text-yellow-500';
|
|
bg = 'bg-yellow-50 dark:bg-yellow-900/20';
|
|
}
|
|
|
|
return `
|
|
<div class="flex items-center gap-3 p-3 ${bg} rounded-lg">
|
|
<i class="fas ${icon}"></i>
|
|
<span class="text-gray-700 dark:text-gray-300">${f.label}</span>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
// 4. Update Metrics (RHR/HRV)
|
|
const rhrVal = latest.resting_heart_rate || '--';
|
|
const hrvVal = latest.hrv ? Math.round(latest.hrv) : '--';
|
|
|
|
document.getElementById('val-rhr').innerText = rhrVal;
|
|
document.getElementById('val-hrv').innerText = hrvVal;
|
|
}
|
|
|
|
// --- Chart Rendering Functions ---
|
|
// Common options
|
|
const commonChartOptions = (isDark) => ({
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { display: false },
|
|
y: { display: false }
|
|
},
|
|
plugins: { legend: { display: false }, tooltip: { enabled: true } },
|
|
elements: { point: { radius: 0, hitRadius: 10 } }
|
|
});
|
|
|
|
function renderRingsChart(steps, pai, cals) {
|
|
const ctx = document.getElementById('chart-rings').getContext('2d');
|
|
if (state.charts.health.rings) state.charts.health.rings.destroy();
|
|
|
|
// Doughnut chart with multiple datasets (rings)
|
|
state.charts.health.rings = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: ['Steps', 'PAI', 'Calories'],
|
|
datasets: [
|
|
{
|
|
data: [steps, 10000 - steps], // Steps (Goal 10k)
|
|
backgroundColor: ['#3b82f6', '#1e293b'],
|
|
borderWidth: 0,
|
|
circumference: 360,
|
|
rotation: 0
|
|
},
|
|
{
|
|
data: [pai, 100 - pai], // PAI (Goal 100)
|
|
backgroundColor: ['#f97316', '#1e293b'],
|
|
borderWidth: 0,
|
|
circumference: 360,
|
|
rotation: 0
|
|
},
|
|
{
|
|
data: [cals, 500 - cals], // Cals (Goal 500)
|
|
backgroundColor: ['#10b981', '#1e293b'],
|
|
borderWidth: 0,
|
|
circumference: 360,
|
|
rotation: 0
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
cutout: '60%', // Thickness
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
|
animation: { animateScale: true, animateRotate: true }
|
|
}
|
|
});
|
|
// Chart.js standard doughnut doesn't do concentric rings easily without plugin.
|
|
// For MVP, single ring for PAI or simplify.
|
|
// Let's do a simple implementation: Just one ring for main metric (Steps) or PAI.
|
|
// Re-doing as Single Ring PAI for simplicity in this iteration to ensure it looks good.
|
|
state.charts.health.rings.destroy();
|
|
|
|
state.charts.health.rings = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: ['PAI', 'Remaining'],
|
|
datasets: [{
|
|
data: [pai, Math.max(0, 100 - pai)],
|
|
backgroundColor: ['#f97316', '#f3f4f6'],
|
|
borderColor: ['#f97316', 'transparent'],
|
|
borderWidth: 0,
|
|
borderRadius: 20
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
cutout: '80%',
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false }, tooltip: { enabled: false } }
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderMiniHrChart(data) {
|
|
const ctx = document.getElementById('chart-mini-hr').getContext('2d');
|
|
if (state.charts.health.miniHr) state.charts.health.miniHr.destroy();
|
|
|
|
const hrData = data.map(d => d.avg_heart_rate);
|
|
const labels = data.map(d => d.date);
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
|
|
// Calc min/max for scale to avoid floating line
|
|
const validHr = hrData.filter(v => v);
|
|
const minVal = validHr.length ? Math.min(...validHr) : 0;
|
|
const maxVal = validHr.length ? Math.max(...validHr) : 100;
|
|
const padding = 5;
|
|
|
|
state.charts.health.miniHr = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
data: hrData,
|
|
borderColor: '#ef4444',
|
|
borderWidth: 2,
|
|
tension: 0.4,
|
|
pointRadius: 0
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { display: false },
|
|
y: { display: false, min: Math.max(0, minVal - padding), max: maxVal + padding }
|
|
},
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
}
|
|
function renderRhrChart(data) {
|
|
if (!data || data.length === 0) return;
|
|
|
|
const ctx = document.getElementById('chart-rhr');
|
|
if (!ctx) return;
|
|
|
|
const context = ctx.getContext('2d');
|
|
if (state.charts.health.rhr) state.charts.health.rhr.destroy();
|
|
|
|
state.charts.health.rhr = new Chart(context, {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.map(d => d.date),
|
|
datasets: [{
|
|
data: data.map(d => d.resting_heart_rate || null),
|
|
borderColor: '#10b981',
|
|
borderWidth: 2,
|
|
tension: 0.4,
|
|
pointRadius: 2
|
|
}]
|
|
},
|
|
options: { ...commonChartOptions(), scales: { x: { display: false }, y: { display: false } } }
|
|
});
|
|
}
|
|
|
|
function renderHrvChart(data) {
|
|
if (!data || data.length === 0) return;
|
|
|
|
const ctx = document.getElementById('chart-hrv');
|
|
if (!ctx) return;
|
|
|
|
const context = ctx.getContext('2d');
|
|
if (state.charts.health.hrv) state.charts.health.hrv.destroy();
|
|
|
|
state.charts.health.hrv = new Chart(context, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: data.map(d => d.date),
|
|
datasets: [{
|
|
data: data.map(d => d.hrv || null),
|
|
backgroundColor: '#3b82f6',
|
|
borderRadius: 2
|
|
}]
|
|
},
|
|
options: { ...commonChartOptions(), scales: { x: { display: false }, y: { display: false } } }
|
|
});
|
|
}
|
|
|
|
function renderHypnogram(dayData) {
|
|
// Safety check
|
|
if (!dayData) {
|
|
console.warn('renderHypnogram: No data provided');
|
|
return;
|
|
}
|
|
|
|
const ctx = document.getElementById('chart-hypnogram');
|
|
if (!ctx) return;
|
|
|
|
const context = ctx.getContext('2d');
|
|
if (state.charts.health.hypno) state.charts.health.hypno.destroy();
|
|
|
|
// Since we don't have time-series stages, we visualize composition as a stacked bar
|
|
const placeholder = document.getElementById('hypno-placeholder');
|
|
if (placeholder) placeholder.style.display = 'none';
|
|
|
|
// Mock discrete blocks for "Hypnogram" feel if we don't have real trace
|
|
// Or just simple stacked bar. Stacked horizontal bar looks like a timeline.
|
|
const deep = dayData.deep_sleep_minutes || 0;
|
|
const light = dayData.light_sleep_minutes || 0;
|
|
const rem = dayData.rem_sleep_minutes || 0;
|
|
const awake = dayData.awake_duration_minutes || 0;
|
|
|
|
state.charts.health.hypno = new Chart(context, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: ['Deep', 'Light', 'REM', 'Awake'],
|
|
datasets: [
|
|
{
|
|
data: [deep, light, rem, awake],
|
|
backgroundColor: ['#9333ea', '#60a5fa', '#facc15', '#d1d5db'],
|
|
borderWidth: 0,
|
|
hoverOffset: 10
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
cutout: '70%',
|
|
plugins: {
|
|
legend: { display: false } // We have custom legend below
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderExertionChart(data) {
|
|
if (!data || data.length === 0) {
|
|
console.warn('renderExertionChart: No data provided');
|
|
return;
|
|
}
|
|
|
|
const ctx = document.getElementById('chart-exertion');
|
|
if (!ctx) return;
|
|
|
|
const context = ctx.getContext('2d');
|
|
if (state.charts.health.exertion) state.charts.health.exertion.destroy();
|
|
|
|
const todayStr = state.currentDate ? state.currentDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0];
|
|
const colors = data.map(d => d.date === todayStr ? '#ffdbcb' : '#f97316'); // Highlight today with lighter orange/whiteish
|
|
|
|
state.charts.health.exertion = new Chart(context, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: data.map(d => new Date(d.date).toLocaleDateString('en-US', { weekday: 'short' })),
|
|
datasets: [{
|
|
label: 'PAI',
|
|
data: data.map(d => d.pai_score),
|
|
backgroundColor: colors,
|
|
borderRadius: 4
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
x: { grid: { display: false } },
|
|
y: {
|
|
grid: { color: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb' },
|
|
title: { display: true, text: 'Score' }
|
|
}
|
|
},
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Toast Notification ---
|
|
function showToast(message, type = 'success') {
|
|
const container = document.getElementById('toast-container');
|
|
if (!container) return;
|
|
|
|
// Icon logic
|
|
let iconHtml = '';
|
|
let colorClass = '';
|
|
|
|
if (type === 'success') {
|
|
colorClass = 'text-green-500 bg-green-100 dark:bg-green-800/30 dark:text-green-400';
|
|
iconHtml = '<i class="fas fa-check"></i>';
|
|
} else if (type === 'error') {
|
|
colorClass = 'text-red-500 bg-red-100 dark:bg-red-800/30 dark:text-red-400';
|
|
iconHtml = '<i class="fas fa-exclamation-triangle"></i>';
|
|
} else {
|
|
colorClass = 'text-blue-500 bg-blue-100 dark:bg-blue-800/30 dark:text-blue-400';
|
|
iconHtml = '<i class="fas fa-info-circle"></i>';
|
|
}
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = 'pointer-events-auto flex items-center w-full max-w-sm p-4 text-gray-500 bg-white rounded-xl shadow-lg dark:text-gray-300 dark:bg-gray-800 border border-gray-100 dark:border-gray-700 transform transition-all duration-300 translate-x-full opacity-0';
|
|
|
|
toast.innerHTML = `
|
|
<div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg ${colorClass}">
|
|
${iconHtml}
|
|
</div>
|
|
<div class="ml-3 text-sm font-medium flex-1">${message}</div>
|
|
<button type="button" class="ml-auto -mx-1.5 -my-1.5 bg-transparent text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:hover:bg-gray-700">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
`;
|
|
|
|
// Close button logic
|
|
const closeBtn = toast.querySelector('button');
|
|
closeBtn.addEventListener('click', () => removeToast(toast));
|
|
|
|
container.appendChild(toast);
|
|
|
|
// Animate In
|
|
requestAnimationFrame(() => {
|
|
toast.classList.remove('translate-x-full', 'opacity-0');
|
|
});
|
|
|
|
// Auto dismiss
|
|
setTimeout(() => {
|
|
removeToast(toast);
|
|
}, 5000);
|
|
}
|
|
|
|
function removeToast(el) {
|
|
el.classList.add('translate-x-full', 'opacity-0');
|
|
// Remove after transition
|
|
setTimeout(() => {
|
|
if (el.parentNode) el.parentNode.removeChild(el);
|
|
}, 300);
|
|
}
|
|
|
|
// --- Metric Gauges & Details Modal ---
|
|
|
|
// State for metric details
|
|
const metricState = {
|
|
currentMetric: 'weight',
|
|
currentPeriod: 'recent',
|
|
referenceDate: null, // Will be initialized on open
|
|
historyChart: null,
|
|
gaugeWeight: null,
|
|
gaugeBMI: null
|
|
};
|
|
|
|
// Create semi-circular gauge
|
|
function createGauge(canvasId, value, min, max, colorStops) {
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas) return null;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = canvas.getBoundingClientRect();
|
|
if (rect.width <= 0 || rect.height <= 0) return null;
|
|
|
|
// Set internal size to match physical pixels
|
|
canvas.width = rect.width * dpr;
|
|
canvas.height = rect.height * dpr;
|
|
|
|
const width = canvas.width;
|
|
const height = canvas.height;
|
|
|
|
const centerX = width / 2;
|
|
// Position the arc in the upper portion to leave space for text below
|
|
const centerY = height * 0.65; // Move center down slightly (was height - 10*dpr)
|
|
const radius = Math.max(0, Math.min(width / 2, height * 0.65) - (20 * dpr));
|
|
|
|
// Clear
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// Background arc
|
|
ctx.beginPath();
|
|
ctx.arc(centerX, centerY, radius, Math.PI, 2 * Math.PI, false);
|
|
ctx.lineWidth = 12 * dpr;
|
|
ctx.strokeStyle = '#1f2937';
|
|
ctx.lineCap = 'round';
|
|
ctx.stroke();
|
|
|
|
// Create gradient for colored arc
|
|
const gradient = ctx.createLinearGradient(centerX - radius, centerY, centerX + radius, centerY);
|
|
colorStops.forEach(stop => {
|
|
gradient.addColorStop(stop.offset, stop.color);
|
|
});
|
|
|
|
// Calculate angle based on value
|
|
const percentage = Math.min(Math.max((value - min) / (max - min), 0), 1);
|
|
const angle = Math.PI + (percentage * Math.PI);
|
|
|
|
// Colored arc
|
|
ctx.beginPath();
|
|
ctx.arc(centerX, centerY, radius, Math.PI, angle, false);
|
|
ctx.lineWidth = 12 * dpr;
|
|
ctx.strokeStyle = gradient;
|
|
ctx.lineCap = 'round';
|
|
ctx.stroke();
|
|
|
|
// Indicator (Diamond/Needle)
|
|
const indicatorAngle = angle;
|
|
const innerR = radius - (15 * dpr);
|
|
const outerR = radius + (15 * dpr);
|
|
|
|
const x1 = centerX + Math.cos(indicatorAngle) * innerR;
|
|
const y1 = centerY + Math.sin(indicatorAngle) * innerR;
|
|
const x2 = centerX + Math.cos(indicatorAngle) * outerR;
|
|
const y2 = centerY + Math.sin(indicatorAngle) * outerR;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
ctx.lineWidth = 4 * dpr;
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.lineCap = 'round';
|
|
ctx.stroke();
|
|
|
|
// Outer point
|
|
ctx.beginPath();
|
|
ctx.arc(x2, y2, 4 * dpr, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fill();
|
|
|
|
return { canvas, ctx, value, min, max };
|
|
}
|
|
|
|
// Initialize gauges when health data is loaded
|
|
function initializeMetricGauges(data, specificLatest = null) {
|
|
if (!data || data.length === 0) return;
|
|
|
|
const latest = specificLatest || data[data.length - 1];
|
|
|
|
// Weight gauge (assuming range 100-200 lbs)
|
|
const weightVal = latest.weight_norm || latest.weight || latest.weight_kg;
|
|
if (weightVal) {
|
|
const weightLbs = (weightVal * 2.20462).toFixed(1);
|
|
// Vibrant color gradient: Cyan -> Green -> Yellow -> Red
|
|
const colorStops = [
|
|
{ offset: 0, color: '#06b6d4' }, // Cyan (100 lbs)
|
|
{ offset: 0.3, color: '#10b981' }, // Green (130 lbs)
|
|
{ offset: 0.7, color: '#fbbf24' }, // Yellow (170 lbs)
|
|
{ offset: 1, color: '#ef4444' } // Red (200 lbs)
|
|
];
|
|
|
|
metricState.gaugeWeight = createGauge('gauge-weight', parseFloat(weightLbs), 100, 200, colorStops);
|
|
|
|
const weightGaugeEl = document.getElementById('val-weight-gauge');
|
|
if (weightGaugeEl) weightGaugeEl.innerText = weightLbs;
|
|
|
|
const weightDateEl = document.getElementById('weight-date');
|
|
if (weightDateEl) weightDateEl.innerText = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
|
|
// Determine status with color
|
|
const bmi = latest.bmi || 0;
|
|
let status = 'BALANCED';
|
|
let statusColor = 'text-cyan-400';
|
|
|
|
if (bmi < 18.5) {
|
|
status = 'UNDERWEIGHT';
|
|
statusColor = 'text-purple-400';
|
|
} else if (bmi >= 18.5 && bmi < 25) {
|
|
status = 'BALANCED';
|
|
statusColor = 'text-cyan-400';
|
|
} else if (bmi >= 25 && bmi < 30) {
|
|
status = 'OVERWEIGHT';
|
|
statusColor = 'text-yellow-400';
|
|
} else if (bmi >= 30) {
|
|
status = 'OBESE';
|
|
statusColor = 'text-red-400';
|
|
}
|
|
|
|
const weightStatusEl = document.getElementById('weight-status');
|
|
if (weightStatusEl) {
|
|
weightStatusEl.innerText = status;
|
|
weightStatusEl.className = `text-sm ${statusColor} uppercase tracking-wider mt-2 font-semibold`;
|
|
}
|
|
}
|
|
|
|
// BMI gauge (assuming range 15-35)
|
|
if (latest.bmi) {
|
|
const bmi = latest.bmi.toFixed(1);
|
|
// Vibrant 5-color gradient: Purple -> Cyan -> Green -> Yellow -> Red
|
|
const colorStops = [
|
|
{ offset: 0, color: '#a855f7' }, // Purple (15)
|
|
{ offset: 0.175, color: '#06b6d4' }, // Cyan (18.5)
|
|
{ offset: 0.5, color: '#10b981' }, // Green (25)
|
|
{ offset: 0.75, color: '#fbbf24' }, // Yellow (30)
|
|
{ offset: 1, color: '#ef4444' } // Red (35)
|
|
];
|
|
|
|
metricState.gaugeBMI = createGauge('gauge-bmi', parseFloat(bmi), 15, 35, colorStops);
|
|
|
|
const bmiGaugeEl = document.getElementById('val-bmi-gauge');
|
|
if (bmiGaugeEl) bmiGaugeEl.innerText = bmi;
|
|
|
|
const bmiDateEl = document.getElementById('bmi-date');
|
|
if (bmiDateEl) bmiDateEl.innerText = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
|
|
// Determine BMI status with appropriate color
|
|
let status = 'NORMAL';
|
|
let statusColor = 'text-green-400';
|
|
|
|
if (bmi < 18.5) {
|
|
status = 'UNDERWEIGHT';
|
|
statusColor = 'text-cyan-400';
|
|
} else if (bmi >= 18.5 && bmi < 25) {
|
|
status = 'NORMAL';
|
|
statusColor = 'text-green-400';
|
|
} else if (bmi >= 25 && bmi < 30) {
|
|
status = 'OVERWEIGHT';
|
|
statusColor = 'text-yellow-400';
|
|
} else if (bmi >= 30) {
|
|
status = 'OBESE';
|
|
statusColor = 'text-red-400';
|
|
}
|
|
|
|
const bmiStatusEl = document.getElementById('bmi-status');
|
|
if (bmiStatusEl) {
|
|
bmiStatusEl.innerText = status;
|
|
bmiStatusEl.className = `text-sm ${statusColor} uppercase tracking-wider mt-2 font-semibold`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Open metric details modal
|
|
// Open metric details modal
|
|
window.openMetricDetails = function (metric) {
|
|
metricState.currentMetric = metric;
|
|
metricState.currentPeriod = 'recent';
|
|
metricState.referenceDate = new Date();
|
|
|
|
const modal = document.getElementById('metric-details-modal');
|
|
const title = document.getElementById('metric-title');
|
|
|
|
// Update title
|
|
title.innerText = metric.toUpperCase();
|
|
|
|
// Show/hide specific sections
|
|
const trendsSection = document.getElementById('metric-trends');
|
|
const bmiInfoSection = document.getElementById('metric-bmi-info');
|
|
|
|
if (metric === 'weight') {
|
|
trendsSection.classList.remove('hidden');
|
|
bmiInfoSection.classList.add('hidden');
|
|
} else {
|
|
trendsSection.classList.add('hidden');
|
|
bmiInfoSection.classList.remove('hidden');
|
|
}
|
|
|
|
// Reset view to chart
|
|
document.getElementById('metric-chart-view').classList.remove('hidden');
|
|
document.getElementById('metric-list-view').classList.add('hidden');
|
|
const historyBtn = document.getElementById('history-data-btn');
|
|
if (historyBtn) {
|
|
historyBtn.innerHTML = '<span class="font-medium">History Data</span><i class="fas fa-list text-gray-400"></i>';
|
|
}
|
|
|
|
// Reset period buttons
|
|
document.querySelectorAll('.metric-period-btn').forEach(btn => {
|
|
btn.classList.remove('bg-brand-600', 'text-white');
|
|
btn.classList.add('text-gray-400');
|
|
});
|
|
const recentBtn = document.querySelector('.metric-period-btn[data-period="recent"]');
|
|
if (recentBtn) {
|
|
recentBtn.classList.add('bg-brand-600', 'text-white');
|
|
recentBtn.classList.remove('text-gray-400');
|
|
}
|
|
|
|
// Show modal
|
|
toggleModal(modal, true);
|
|
|
|
// Load data
|
|
loadMetricHistory(metric, 'recent');
|
|
};
|
|
|
|
// Load metric history
|
|
async function loadMetricHistory(metric, period) {
|
|
try {
|
|
const refDate = metricState.referenceDate || state.currentDate || new Date();
|
|
const dateStr = refDate.toISOString().split('T')[0];
|
|
|
|
// Always fetch from API for the specific period to get full history (especially for 'year')
|
|
const apiPeriod = (period === 'recent') ? 'week' : period;
|
|
const res = await fetch(`${API_URL}/health/stats?period=${apiPeriod}&ref_date=${dateStr}`);
|
|
if (!res.ok) throw new Error("Failed to fetch history");
|
|
const data = await res.json();
|
|
|
|
if (!data || data.length === 0) {
|
|
console.warn('No health data available');
|
|
return;
|
|
}
|
|
|
|
const heightM = 1.75; // Default height for BMI calc if missing
|
|
|
|
// Add BMI to data if missing
|
|
const processedData = data.map(d => {
|
|
const w = d.weight || d.weight_kg;
|
|
return {
|
|
...d,
|
|
weight_norm: w,
|
|
bmi: d.bmi || (w ? (w / (heightM * heightM)) : null)
|
|
};
|
|
});
|
|
|
|
// Update current value display (latest valid value in the dataset)
|
|
const validEntries = processedData.filter(d => metric === 'weight' ? d.weight_norm > 0 : d.bmi > 0);
|
|
const latest = validEntries.length > 0 ? validEntries[validEntries.length - 1] : processedData[processedData.length - 1];
|
|
|
|
if (metric === 'weight' && (latest.weight_norm || latest.weight)) {
|
|
const w = latest.weight_norm || latest.weight;
|
|
const weightLbs = (w * 2.20462).toFixed(1);
|
|
document.getElementById('metric-current-value').innerText = weightLbs + ' lbs';
|
|
} else if (metric === 'bmi' && latest.bmi) {
|
|
document.getElementById('metric-current-value').innerText = latest.bmi.toFixed(1);
|
|
}
|
|
|
|
document.getElementById('metric-current-date').innerText = new Date(latest.date).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
|
|
// Calculate stats based on ALL data for the period
|
|
const values = processedData.map(d => metric === 'weight' ? (d.weight_norm ? d.weight_norm * 2.20462 : null) : d.bmi).filter(v => v);
|
|
|
|
if (values.length > 0) {
|
|
const avg = (values.reduce((a, b) => a + b, 0) / values.length).toFixed(1);
|
|
const max = Math.max(...values).toFixed(1);
|
|
const min = Math.min(...values).toFixed(1);
|
|
|
|
document.getElementById('metric-avg').innerText = avg + (metric === 'weight' ? ' lbs' : '');
|
|
document.getElementById('metric-max').innerText = max + (metric === 'weight' ? ' lbs' : '');
|
|
document.getElementById('metric-min').innerText = min + (metric === 'weight' ? ' lbs' : '');
|
|
|
|
// TRENDS for Weight
|
|
if (metric === 'weight') {
|
|
const earliest = values[0];
|
|
const latestVal = values[values.length - 1];
|
|
const change = latestVal - earliest;
|
|
|
|
const trendProgressEl = document.getElementById('trend-progress');
|
|
if (trendProgressEl) {
|
|
const icon = change > 0 ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
|
|
const color = change > 0 ? 'text-red-500' : 'text-green-500';
|
|
const sign = change > 0 ? '+' : '';
|
|
trendProgressEl.parentElement.className = color;
|
|
trendProgressEl.innerHTML = `${icon} ${sign}${change.toFixed(1)} lbs`;
|
|
trendProgressEl.previousElementSibling.outerHTML = icon; // Update icon
|
|
}
|
|
|
|
const trendCompEl = document.getElementById('trend-comparison');
|
|
if (trendCompEl) {
|
|
const diffAvg = latestVal - parseFloat(avg);
|
|
const icon = diffAvg > 0 ? '<i class="fas fa-arrow-up"></i>' : '<i class="fas fa-arrow-down"></i>';
|
|
const color = diffAvg > 0 ? 'text-red-500' : 'text-green-500';
|
|
const sign = diffAvg > 0 ? '+' : '';
|
|
trendCompEl.parentElement.className = color;
|
|
trendCompEl.innerHTML = `vs Avg: ${sign}${diffAvg.toFixed(1)} lbs`;
|
|
// trendCompEl.previousElementSibling.outerHTML = icon;
|
|
}
|
|
|
|
// Ideal Weight (BMI 22)
|
|
// heightM = 1.75 default
|
|
const idealKg = 22 * (1.75 * 1.75);
|
|
const idealLbs = idealKg * 2.20462;
|
|
const idealEl = document.getElementById('ideal-weight');
|
|
if (idealEl) idealEl.innerText = idealLbs.toFixed(1) + ' lbs';
|
|
}
|
|
}
|
|
|
|
// Render chart with full history
|
|
renderMetricChart(processedData, metric);
|
|
|
|
// Render table
|
|
renderMetricTable(processedData, metric);
|
|
|
|
} catch (err) {
|
|
console.error('Error loading metric history:', err);
|
|
showToast("Failed to load history data", "error");
|
|
}
|
|
}
|
|
|
|
// Render metric history chart
|
|
function renderMetricChart(data, metric) {
|
|
const canvas = document.getElementById('metric-history-chart');
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Destroy existing chart
|
|
if (metricState.historyChart) {
|
|
metricState.historyChart.destroy();
|
|
}
|
|
|
|
// Prepare data
|
|
const labels = data.map(d => new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
|
|
const values = data.map(d => {
|
|
if (metric === 'weight') {
|
|
const w = d.weight_norm || d.weight || d.weight_kg;
|
|
return w ? (w * 2.20462).toFixed(1) : null;
|
|
} else {
|
|
return d.bmi ? d.bmi.toFixed(1) : null;
|
|
}
|
|
});
|
|
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
|
|
metricState.historyChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: metric === 'weight' ? 'Weight (lbs)' : 'BMI',
|
|
data: values,
|
|
borderColor: '#10b981',
|
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
|
tension: 0.4,
|
|
fill: true,
|
|
pointRadius: 4,
|
|
pointBackgroundColor: '#10b981',
|
|
pointBorderColor: '#fff',
|
|
pointBorderWidth: 2,
|
|
pointHoverRadius: 6
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
},
|
|
tooltip: {
|
|
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
|
titleColor: isDark ? '#ffffff' : '#000000',
|
|
bodyColor: isDark ? '#9ca3af' : '#6b7280',
|
|
borderColor: isDark ? '#374151' : '#e5e7eb',
|
|
borderWidth: 1,
|
|
padding: 12,
|
|
displayColors: false
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: false,
|
|
grid: {
|
|
color: isDark ? '#374151' : '#e5e7eb',
|
|
drawBorder: false
|
|
},
|
|
ticks: {
|
|
color: isDark ? '#9ca3af' : '#6b7280',
|
|
font: {
|
|
size: 11
|
|
}
|
|
}
|
|
},
|
|
x: {
|
|
grid: {
|
|
display: false
|
|
},
|
|
ticks: {
|
|
color: isDark ? '#9ca3af' : '#6b7280',
|
|
font: {
|
|
size: 11
|
|
},
|
|
maxRotation: 45,
|
|
minRotation: 45
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderMetricTable(data, metric) {
|
|
const tbody = document.getElementById('metric-history-table-body');
|
|
const countEl = document.getElementById('history-entry-count');
|
|
if (!tbody) return;
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
// Filter valid data and sort desc
|
|
const validData = data.filter(d => metric === 'weight' ? (d.weight_norm || d.weight) : d.bmi)
|
|
.sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
|
|
if (countEl) countEl.innerText = `${validData.length} entries`;
|
|
|
|
if (validData.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="3" class="p-4 text-center text-gray-500">No data available</td></tr>';
|
|
return;
|
|
}
|
|
|
|
validData.forEach((d, i) => {
|
|
const val = metric === 'weight' ? (d.weight_norm || d.weight) * 2.20462 : d.bmi;
|
|
const prevVal = (i < validData.length - 1) ? (metric === 'weight' ? (validData[i + 1].weight_norm || validData[i + 1].weight) * 2.20462 : validData[i + 1].bmi) : null;
|
|
|
|
let changeHtml = '<span class="text-gray-400">-</span>';
|
|
if (prevVal) {
|
|
const diff = val - prevVal;
|
|
if (Math.abs(diff) < 0.1) {
|
|
changeHtml = '<span class="text-gray-400">-</span>';
|
|
} else if (diff > 0) {
|
|
changeHtml = `<span class="text-red-500"><i class="fas fa-caret-up"></i> +${diff.toFixed(1)}</span>`;
|
|
} else {
|
|
changeHtml = `<span class="text-green-500"><i class="fas fa-caret-down"></i> ${diff.toFixed(1)}</span>`;
|
|
}
|
|
// Invert colors for Weight if gaining is bad? Usually gaining weight is bad for weight loss apps.
|
|
// But if metric is generic, leave it. Assuming weight loss goal: Red = Up, Green = Down.
|
|
}
|
|
|
|
const date = new Date(d.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
|
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'hover:bg-gray-700/30 transition-colors';
|
|
tr.innerHTML = `
|
|
<td class="p-4 text-gray-300">${date}</td>
|
|
<td class="p-4 text-right font-bold text-white">${val.toFixed(1)} ${metric === 'weight' ? 'lbs' : ''}</td>
|
|
<td class="p-4 text-right">${changeHtml}</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
// Initialize metric modal handlers
|
|
function initMetricModal() {
|
|
const modal = document.getElementById('metric-details-modal');
|
|
const closeBtn = document.getElementById('close-metric-modal-btn');
|
|
const backBtn = document.getElementById('metric-back-btn');
|
|
const backdrop = document.getElementById('metric-modal-backdrop');
|
|
|
|
if (!modal) return;
|
|
|
|
const closeModal = () => {
|
|
toggleModal(modal, false);
|
|
if (metricState.historyChart) {
|
|
metricState.historyChart.destroy();
|
|
metricState.historyChart = null;
|
|
}
|
|
};
|
|
|
|
if (closeBtn) closeBtn.addEventListener('click', closeModal);
|
|
if (backBtn) backBtn.addEventListener('click', closeModal);
|
|
if (backdrop) backdrop.addEventListener('click', closeModal);
|
|
|
|
// Period buttons
|
|
document.querySelectorAll('.metric-period-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const period = btn.dataset.period;
|
|
metricState.currentPeriod = period;
|
|
metricState.referenceDate = new Date(); // Reset date on period change
|
|
|
|
// Update button states
|
|
document.querySelectorAll('.metric-period-btn').forEach(b => {
|
|
b.classList.remove('bg-brand-600', 'text-white');
|
|
b.classList.add('text-gray-400');
|
|
});
|
|
btn.classList.add('bg-brand-600', 'text-white');
|
|
btn.classList.remove('text-gray-400');
|
|
|
|
// Reload data
|
|
loadMetricHistory(metricState.currentMetric, period);
|
|
});
|
|
});
|
|
|
|
// Navigation Buttons
|
|
const prevBtn = document.getElementById('metric-prev-btn');
|
|
const nextBtn = document.getElementById('metric-next-btn');
|
|
|
|
const changeTime = (direction) => {
|
|
const d = new Date(metricState.referenceDate);
|
|
const period = metricState.currentPeriod;
|
|
|
|
if (period === 'recent' || period === 'week') {
|
|
d.setDate(d.getDate() + (direction * 7));
|
|
} else if (period === 'month') {
|
|
d.setMonth(d.getMonth() + direction);
|
|
} else if (period === 'year') {
|
|
d.setFullYear(d.getFullYear() + direction);
|
|
}
|
|
|
|
// Prevent future dates? Optional, but good practice usually.
|
|
// if (d > new Date()) return;
|
|
|
|
metricState.referenceDate = d;
|
|
loadMetricHistory(metricState.currentMetric, period);
|
|
};
|
|
|
|
if (prevBtn) prevBtn.addEventListener('click', () => changeTime(-1));
|
|
if (nextBtn) nextBtn.addEventListener('click', () => changeTime(1));
|
|
|
|
// History data button (Toggle View)
|
|
const historyBtn = document.getElementById('history-data-btn');
|
|
if (historyBtn) {
|
|
historyBtn.addEventListener('click', () => {
|
|
const chartView = document.getElementById('metric-chart-view');
|
|
const listView = document.getElementById('metric-list-view');
|
|
|
|
if (chartView.classList.contains('hidden')) {
|
|
// Show Chart
|
|
chartView.classList.remove('hidden');
|
|
listView.classList.add('hidden');
|
|
historyBtn.innerHTML = '<span class="font-medium">History Data</span><i class="fas fa-list text-gray-400"></i>';
|
|
} else {
|
|
// Show List
|
|
chartView.classList.add('hidden');
|
|
listView.classList.remove('hidden');
|
|
historyBtn.innerHTML = '<span class="font-medium">Show Chart</span><i class="fas fa-chart-line text-gray-400"></i>';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add entry button (placeholder)
|
|
const addBtn = document.getElementById('metric-add-btn');
|
|
if (addBtn) {
|
|
addBtn.addEventListener('click', () => {
|
|
showToast('Manual entry coming soon!', 'info');
|
|
});
|
|
}
|
|
}
|
|
|
|
// Initialize on DOMContentLoaded
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initMetricModal();
|
|
initWorkoutsTab();
|
|
// Initialize period views to match default state
|
|
switchPeriodViews(state.healthPeriod || 'day');
|
|
});
|
|
|
|
// ===== PERIOD-ADAPTIVE VIEW SYSTEM =====
|
|
|
|
// Chart instances for period views
|
|
const periodCharts = {
|
|
weekPai: null,
|
|
monthSteps: null,
|
|
monthSleep: null
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
* Render week overview - PAI bar chart with goal line + mini rings
|
|
*/
|
|
function renderWeekOverview(data) {
|
|
if (!data || !data.daily) return;
|
|
|
|
const dailyData = data.daily || [];
|
|
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
|
|
// Calculate totals
|
|
let totalPai = 0;
|
|
let totalSteps = 0;
|
|
let totalCals = 0;
|
|
let totalSleep = 0;
|
|
let totalRhr = 0;
|
|
let countRhr = 0;
|
|
|
|
dailyData.forEach((d, i) => {
|
|
totalPai += d.pai || 0;
|
|
totalSteps += d.steps || 0;
|
|
totalCals += d.calories || 0;
|
|
totalSleep += d.sleep_duration || 0;
|
|
if (d.resting_hr) {
|
|
totalRhr += d.resting_hr;
|
|
countRhr++;
|
|
}
|
|
|
|
// Update mini rings
|
|
const ringEl = document.getElementById(`mini-ring-${i}`);
|
|
if (ringEl) {
|
|
const ring = ringEl.querySelector('div');
|
|
const goalReached = (d.steps || 0) >= 8000; // Example goal
|
|
if (ring) {
|
|
ring.className = `w-10 h-10 mx-auto rounded-full border-4 ${goalReached ? 'border-green-500 bg-green-500/20' : 'border-gray-600'}`;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Update summary values
|
|
const avgPai = dailyData.length > 0 ? totalPai / dailyData.length : 0;
|
|
document.getElementById('week-total-pai').innerText = Math.round(avgPai);
|
|
document.getElementById('week-avg-steps').innerText = Math.round(totalSteps / Math.max(dailyData.length, 1)).toLocaleString();
|
|
document.getElementById('week-avg-cals').innerText = Math.round(totalCals / Math.max(dailyData.length, 1)).toLocaleString();
|
|
|
|
const avgSleepHrs = (totalSleep / Math.max(dailyData.length, 1)) / 60;
|
|
document.getElementById('week-avg-sleep').innerText = `${Math.floor(avgSleepHrs)}h ${Math.round((avgSleepHrs % 1) * 60)}m`;
|
|
document.getElementById('week-avg-rhr').innerText = countRhr > 0 ? Math.round(totalRhr / countRhr) : '--';
|
|
|
|
// PAI Bar Chart
|
|
const ctx = document.getElementById('chart-week-pai');
|
|
if (!ctx) return;
|
|
|
|
if (periodCharts.weekPai) periodCharts.weekPai.destroy();
|
|
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const paiData = dailyData.map(d => d.pai || 0);
|
|
const labels = dailyData.map((d, i) => days[i] || `Day ${i + 1}`);
|
|
|
|
|
|
periodCharts.weekPai = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'PAI Score',
|
|
data: paiData,
|
|
backgroundColor: paiData.map(v => v >= 15 ? '#10b981' : '#f97316'),
|
|
borderRadius: 8,
|
|
barThickness: 40
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
annotation: {
|
|
annotations: {
|
|
goalLine: {
|
|
type: 'line',
|
|
yMin: 15,
|
|
yMax: 15,
|
|
borderColor: '#ef4444',
|
|
borderWidth: 2,
|
|
borderDash: [6, 6],
|
|
label: {
|
|
content: 'Daily Goal',
|
|
enabled: true,
|
|
position: 'end'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
max: Math.max(...paiData, 30),
|
|
grid: { color: isDark ? '#374151' : '#e5e7eb' },
|
|
ticks: { color: isDark ? '#9ca3af' : '#6b7280' }
|
|
},
|
|
x: {
|
|
grid: { display: false },
|
|
ticks: { color: isDark ? '#9ca3af' : '#6b7280' }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render month overview - Activity heatmap + trend charts
|
|
*/
|
|
function renderMonthOverview(data) {
|
|
if (!data || !data.daily) return;
|
|
|
|
const dailyData = data.daily || [];
|
|
const container = document.getElementById('heatmap-container');
|
|
if (!container) return;
|
|
|
|
// Clear existing
|
|
container.innerHTML = '';
|
|
|
|
// Add day headers
|
|
const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
dayLabels.forEach(day => {
|
|
const header = document.createElement('div');
|
|
header.className = 'text-center text-xs text-gray-400 font-medium py-1';
|
|
header.textContent = day;
|
|
container.appendChild(header);
|
|
});
|
|
|
|
// Calculate max for intensity scaling
|
|
const maxSteps = Math.max(...dailyData.map(d => d.steps || 0), 10000);
|
|
let activeDays = 0;
|
|
let restDays = 0;
|
|
|
|
// Create heatmap cells
|
|
dailyData.forEach(d => {
|
|
const steps = d.steps || 0;
|
|
const intensity = steps / maxSteps;
|
|
|
|
let colorClass = 'bg-gray-700';
|
|
if (intensity > 0.1) colorClass = 'bg-green-900';
|
|
if (intensity > 0.3) colorClass = 'bg-green-700';
|
|
if (intensity > 0.5) colorClass = 'bg-green-500';
|
|
if (intensity > 0.7) colorClass = 'bg-green-400';
|
|
|
|
if (steps > 3000) activeDays++;
|
|
else restDays++;
|
|
|
|
const cell = document.createElement('div');
|
|
cell.className = `aspect-square rounded ${colorClass} cursor-pointer hover:ring-2 hover:ring-white/50 transition-all`;
|
|
cell.title = `${d.date}: ${steps.toLocaleString()} steps`;
|
|
container.appendChild(cell);
|
|
});
|
|
|
|
document.getElementById('month-active-days').innerText = activeDays;
|
|
document.getElementById('month-rest-days').innerText = restDays;
|
|
|
|
// Render trend charts
|
|
renderMonthTrendCharts(dailyData);
|
|
}
|
|
|
|
/**
|
|
* Render Sleep Week View
|
|
*/
|
|
function renderSleepWeek(dailyData) {
|
|
if (!dailyData || dailyData.length === 0) return;
|
|
|
|
// 1. Calculate Averages
|
|
let totalDur = 0;
|
|
let bedtimes = [];
|
|
let waketimes = [];
|
|
let bestDur = 0;
|
|
let validDays = 0;
|
|
|
|
dailyData.forEach(d => {
|
|
if (d.sleep_duration > 0) {
|
|
totalDur += d.sleep_duration;
|
|
validDays++;
|
|
if (d.sleep_duration > bestDur) bestDur = d.sleep_duration;
|
|
}
|
|
if (d.bedtime) {
|
|
const bt = new Date(d.bedtime);
|
|
let mins = bt.getHours() * 60 + bt.getMinutes();
|
|
if (mins > 12 * 60) mins -= 24 * 60;
|
|
bedtimes.push(mins);
|
|
}
|
|
if (d.wake_time) {
|
|
const wt = new Date(d.wake_time);
|
|
waketimes.push(wt.getHours() * 60 + wt.getMinutes());
|
|
}
|
|
});
|
|
|
|
const avgDur = validDays > 0 ? totalDur / validDays : 0;
|
|
document.getElementById('week-avg-duration').innerText = `${Math.floor(avgDur / 60)}h ${Math.round(avgDur % 60)}m`;
|
|
document.getElementById('week-best-night').innerText = `${Math.floor(bestDur / 60)}h ${Math.round(bestDur % 60)}m`;
|
|
|
|
if (bedtimes.length > 0) {
|
|
const avgBtMins = bedtimes.reduce((a, b) => a + b, 0) / bedtimes.length;
|
|
const totalMins = avgBtMins + (avgBtMins < 0 ? 1440 : 0);
|
|
const h = Math.floor(totalMins / 60) % 24;
|
|
const m = Math.round(totalMins % 60);
|
|
document.getElementById('week-avg-bedtime').innerText = `${h}:${m.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
if (waketimes.length > 0) {
|
|
const avgWtMins = waketimes.reduce((a, b) => a + b, 0) / waketimes.length;
|
|
const h = Math.floor(avgWtMins / 60);
|
|
const m = Math.round(avgWtMins % 60);
|
|
document.getElementById('week-avg-wake').innerText = `${h}:${m.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
document.getElementById('sleep-regularity-score').innerText = validDays > 4 ? 'High' : 'Moderate';
|
|
renderSleepScheduleChart(dailyData);
|
|
}
|
|
|
|
/**
|
|
* Render Sleep Month View
|
|
*/
|
|
function renderSleepMonth(dailyData) {
|
|
if (!dailyData || dailyData.length === 0) return;
|
|
|
|
let totalDur = 0;
|
|
let nightsBelow6 = 0;
|
|
let validDays = 0;
|
|
|
|
dailyData.forEach(d => {
|
|
if (d.sleep_duration > 0) {
|
|
totalDur += d.sleep_duration;
|
|
validDays++;
|
|
if (d.sleep_duration < 360) nightsBelow6++;
|
|
}
|
|
});
|
|
|
|
const avgDur = validDays > 0 ? totalDur / validDays : 0;
|
|
document.getElementById('month-avg-sleep').innerText = `${Math.floor(avgDur / 60)}h ${Math.round(avgDur % 60)}m`;
|
|
document.getElementById('month-nights-below').innerText = nightsBelow6;
|
|
|
|
const goalMins = 8 * 60;
|
|
const debtMins = (goalMins * validDays) - totalDur;
|
|
const debtEl = document.getElementById('month-sleep-debt');
|
|
if (debtEl) {
|
|
if (debtMins > 0) {
|
|
debtEl.innerHTML = `<span class="text-red-500">-${Math.floor(debtMins / 60)}h ${Math.round(debtMins % 60)}m</span>`;
|
|
} else {
|
|
debtEl.innerHTML = `<span class="text-green-500">+${Math.floor(Math.abs(debtMins) / 60)}h ${Math.round(Math.abs(debtMins) % 60)}m</span>`;
|
|
}
|
|
}
|
|
|
|
renderSleepDebtChart(dailyData);
|
|
}
|
|
|
|
function renderSleepScheduleChart(dailyData) {
|
|
const ctx = document.getElementById('chart-sleep-schedule');
|
|
if (!ctx) return;
|
|
if (periodCharts.sleepSchedule) periodCharts.sleepSchedule.destroy();
|
|
|
|
const formattedData = dailyData.map(d => {
|
|
if (!d.bedtime || !d.wake_time) return null;
|
|
const bt = new Date(d.bedtime);
|
|
const wt = new Date(d.wake_time);
|
|
|
|
let bVal = bt.getHours() + (bt.getMinutes() / 60);
|
|
if (bVal >= 12) bVal -= 12; else bVal += 12;
|
|
|
|
let wVal = wt.getHours() + (wt.getMinutes() / 60);
|
|
if (wVal >= 12) wVal -= 12; else wVal += 12;
|
|
|
|
return [bVal, wVal];
|
|
});
|
|
|
|
periodCharts.sleepSchedule = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: dailyData.map(d => new Date(d.date).toLocaleDateString('en-US', { weekday: 'short' })),
|
|
datasets: [{
|
|
label: 'Sleep Window',
|
|
data: formattedData,
|
|
backgroundColor: 'rgba(147, 51, 234, 0.6)',
|
|
borderColor: '#9333ea',
|
|
borderWidth: 1,
|
|
borderRadius: 20,
|
|
borderSkipped: false
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
indexAxis: 'x',
|
|
scales: {
|
|
y: {
|
|
min: 6, max: 24,
|
|
ticks: {
|
|
stepSize: 2,
|
|
callback: function (value) {
|
|
let h = (value + 12) % 24;
|
|
return (h === 0 ? 12 : (h > 12 ? h - 12 : h)) + (h >= 12 ? ' PM' : ' AM');
|
|
}
|
|
}
|
|
}
|
|
},
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderSleepDebtChart(dailyData) {
|
|
const ctx = document.getElementById('chart-sleep-debt');
|
|
if (!ctx) return;
|
|
if (periodCharts.sleepDebt) periodCharts.sleepDebt.destroy();
|
|
|
|
periodCharts.sleepDebt = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: dailyData.map(d => d.date.split('-')[2]),
|
|
datasets: [{
|
|
data: dailyData.map(d => d.sleep_duration / 60),
|
|
backgroundColor: dailyData.map(d => {
|
|
const h = d.sleep_duration / 60;
|
|
if (h < 6) return '#ef4444';
|
|
if (h < 7) return '#f59e0b';
|
|
return '#10b981';
|
|
}),
|
|
borderRadius: 4
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
scales: {
|
|
y: { max: 12, title: { display: true, text: 'Hours' } },
|
|
x: { ticks: { maxTicksLimit: 10 } }
|
|
},
|
|
plugins: { legend: { display: false } }
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render monthly trend line charts
|
|
*/
|
|
function renderMonthTrendCharts(dailyData) {
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const labels = dailyData.map((d, i) => i + 1);
|
|
|
|
// Steps trend
|
|
const stepsCtx = document.getElementById('chart-month-steps');
|
|
if (stepsCtx) {
|
|
if (periodCharts.monthSteps) periodCharts.monthSteps.destroy();
|
|
|
|
periodCharts.monthSteps = new Chart(stepsCtx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Steps',
|
|
data: dailyData.map(d => d.steps || 0),
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
fill: true,
|
|
tension: 0.4,
|
|
pointRadius: 0
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
grid: { color: isDark ? '#374151' : '#e5e7eb' }
|
|
},
|
|
x: {
|
|
grid: { display: false },
|
|
ticks: { maxTicksLimit: 5 }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Sleep trend
|
|
const sleepCtx = document.getElementById('chart-month-sleep');
|
|
if (sleepCtx) {
|
|
if (periodCharts.monthSleep) periodCharts.monthSleep.destroy();
|
|
|
|
periodCharts.monthSleep = new Chart(sleepCtx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Sleep (hrs)',
|
|
data: dailyData.map(d => (d.sleep_duration || 0) / 60),
|
|
backgroundColor: dailyData.map(d => {
|
|
const hrs = (d.sleep_duration || 0) / 60;
|
|
if (hrs < 6) return '#ef4444';
|
|
if (hrs < 7) return '#f59e0b';
|
|
return '#10b981';
|
|
}),
|
|
borderRadius: 2
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
max: 12,
|
|
grid: { color: isDark ? '#374151' : '#e5e7eb' }
|
|
},
|
|
x: {
|
|
grid: { display: false },
|
|
ticks: { maxTicksLimit: 5 }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Period View Switching ---
|
|
function switchPeriodViews(period) {
|
|
// Map period to view suffix
|
|
const viewMap = {
|
|
'day': 'day',
|
|
'week': 'week',
|
|
'month': 'month'
|
|
};
|
|
|
|
const suffix = viewMap[period] || 'day';
|
|
|
|
// Get all tabs
|
|
const tabs = ['overview', 'readiness', 'sleep', 'exertion', 'workouts'];
|
|
|
|
tabs.forEach(tab => {
|
|
// Hide all period views for this tab
|
|
['day', 'week', 'month'].forEach(p => {
|
|
const viewEl = document.getElementById(`${tab}-${p}`);
|
|
if (viewEl) {
|
|
viewEl.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Show the selected period view
|
|
const activeView = document.getElementById(`${tab}-${suffix}`);
|
|
if (activeView) {
|
|
activeView.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
// Render period-specific data for workouts
|
|
if (period === 'month') {
|
|
renderWorkoutsMonth();
|
|
} else if (period === 'week') {
|
|
renderWorkoutsWeek();
|
|
} else {
|
|
renderWorkoutsDay();
|
|
}
|
|
}
|
|
|
|
|
|
// --- Workouts Tab ---
|
|
const workoutState = {
|
|
workouts: [],
|
|
summary: null,
|
|
charts: {
|
|
types: null,
|
|
weekly: null
|
|
}
|
|
};
|
|
|
|
function initWorkoutsTab() {
|
|
// Filter handler
|
|
const filterEl = document.getElementById('workout-filter');
|
|
if (filterEl) {
|
|
filterEl.addEventListener('change', () => {
|
|
renderWorkoutsList(workoutState.workouts, filterEl.value);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function loadWorkoutsData(startDate, endDate) {
|
|
try {
|
|
let workoutsUrl = `${API_URL}/health/workouts?limit=200`;
|
|
let summaryUrl = `${API_URL}/health/workouts/summary`;
|
|
|
|
if (startDate && endDate) {
|
|
workoutsUrl += `&start_date=${startDate}&end_date=${endDate}`;
|
|
summaryUrl += `?start_date=${startDate}&end_date=${endDate}`;
|
|
}
|
|
|
|
// Load workouts list
|
|
const workoutsRes = await fetch(workoutsUrl);
|
|
if (workoutsRes.ok) {
|
|
workoutState.workouts = await workoutsRes.json();
|
|
}
|
|
|
|
// Load summary
|
|
const summaryRes = await fetch(summaryUrl);
|
|
if (summaryRes.ok) {
|
|
workoutState.summary = await summaryRes.json();
|
|
}
|
|
|
|
renderWorkoutsTab();
|
|
} catch (err) {
|
|
console.error('Error loading workouts:', err);
|
|
}
|
|
}
|
|
|
|
function renderWorkoutsTab() {
|
|
const summary = workoutState.summary;
|
|
const workouts = workoutState.workouts;
|
|
|
|
if (!summary) return;
|
|
|
|
// Summary cards
|
|
document.getElementById('workout-total').innerText = summary.total_workouts || 0;
|
|
|
|
// Calculate totals from by_activity_type
|
|
let totalDuration = 0;
|
|
let totalDistance = 0;
|
|
let totalCalories = 0;
|
|
|
|
summary.by_activity_type.forEach(t => {
|
|
totalDuration += t.total_duration_seconds || 0;
|
|
totalDistance += t.total_distance_meters || 0;
|
|
totalCalories += t.total_calories || 0;
|
|
});
|
|
|
|
// Format duration
|
|
const hours = Math.floor(totalDuration / 3600);
|
|
const minutes = Math.floor((totalDuration % 3600) / 60);
|
|
document.getElementById('workout-duration').innerText = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
|
|
// Format distance
|
|
const km = (totalDistance / 1000).toFixed(1);
|
|
document.getElementById('workout-distance').innerText = `${km} km`;
|
|
|
|
// Format calories
|
|
document.getElementById('workout-calories').innerText = Math.round(totalCalories).toLocaleString();
|
|
|
|
// Charts
|
|
renderWorkoutTypesChart(summary.by_activity_type);
|
|
renderWorkoutWeeklyChart(workouts);
|
|
|
|
// List
|
|
renderWorkoutsList(workouts);
|
|
}
|
|
|
|
function renderWorkoutTypesChart(data) {
|
|
const ctx = document.getElementById('chart-workout-types');
|
|
if (!ctx) return;
|
|
|
|
if (workoutState.charts.types) {
|
|
workoutState.charts.types.destroy();
|
|
}
|
|
|
|
const colors = [
|
|
'#f97316', // orange
|
|
'#3b82f6', // blue
|
|
'#10b981', // green
|
|
'#8b5cf6', // purple
|
|
'#ec4899', // pink
|
|
'#14b8a6', // teal
|
|
'#f59e0b', // amber
|
|
'#6366f1' // indigo
|
|
];
|
|
|
|
workoutState.charts.types = new Chart(ctx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: data.map(d => d.activity_type),
|
|
datasets: [{
|
|
data: data.map(d => d.count),
|
|
backgroundColor: colors.slice(0, data.length),
|
|
borderWidth: 0,
|
|
hoverOffset: 10
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
cutout: '65%',
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom',
|
|
labels: {
|
|
padding: 15,
|
|
usePointStyle: true,
|
|
color: document.documentElement.classList.contains('dark') ? '#d1d5db' : '#374151'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderWorkoutWeeklyChart(workouts) {
|
|
const ctx = document.getElementById('chart-workout-weekly');
|
|
if (!ctx) return;
|
|
|
|
if (workoutState.charts.weekly) {
|
|
workoutState.charts.weekly.destroy();
|
|
}
|
|
|
|
// Group workouts by week
|
|
const weeklyData = {};
|
|
const now = new Date();
|
|
|
|
// Initialize last 12 weeks
|
|
for (let i = 11; i >= 0; i--) {
|
|
const weekStart = new Date(now);
|
|
weekStart.setDate(now.getDate() - (i * 7));
|
|
const weekKey = weekStart.toISOString().split('T')[0].slice(0, 7) + '-W' + getWeekNumber(weekStart);
|
|
weeklyData[weekKey] = { count: 0, duration: 0, calories: 0 };
|
|
}
|
|
|
|
workouts.forEach(w => {
|
|
const date = new Date(w.start_time);
|
|
const weekKey = date.toISOString().split('T')[0].slice(0, 7) + '-W' + getWeekNumber(date);
|
|
if (weeklyData[weekKey]) {
|
|
weeklyData[weekKey].count++;
|
|
weeklyData[weekKey].duration += (w.duration_seconds || 0) / 60;
|
|
weeklyData[weekKey].calories += w.calories || 0;
|
|
}
|
|
});
|
|
|
|
const labels = Object.keys(weeklyData).map(k => k.split('-W')[1] ? 'W' + k.split('-W')[1] : k);
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
|
|
workoutState.charts.weekly = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels.slice(-8),
|
|
datasets: [{
|
|
label: 'Workouts',
|
|
data: Object.values(weeklyData).slice(-8).map(d => d.count),
|
|
backgroundColor: '#f97316',
|
|
borderRadius: 6
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
grid: { color: isDark ? '#374151' : '#e5e7eb' },
|
|
ticks: { stepSize: 1 }
|
|
},
|
|
x: {
|
|
grid: { display: false }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function getWeekNumber(date) {
|
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
const dayNum = d.getUTCDay() || 7;
|
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
|
}
|
|
|
|
function renderWorkoutsList(workouts, filterType = '') {
|
|
const container = document.getElementById('workouts-list');
|
|
if (!container) return;
|
|
|
|
let filtered = workouts;
|
|
if (filterType) {
|
|
filtered = workouts.filter(w => w.activity_type === filterType);
|
|
}
|
|
|
|
if (filtered.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="p-8 text-center text-gray-400">
|
|
<i class="fas fa-dumbbell text-4xl mb-3 opacity-50"></i>
|
|
<p>No workouts found</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = filtered.slice(0, 50).map(w => {
|
|
const date = new Date(w.start_time);
|
|
const duration = formatDuration(w.duration_seconds);
|
|
const distance = (w.distance_meters / 1000).toFixed(2);
|
|
const icon = getActivityIcon(w.activity_type);
|
|
const color = getActivityColor(w.activity_type);
|
|
|
|
return `
|
|
<div class="p-4 flex items-center gap-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
|
<div class="w-12 h-12 rounded-xl ${color} flex items-center justify-center text-white shadow-md">
|
|
<i class="${icon} text-lg"></i>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<h4 class="font-semibold text-gray-900 dark:text-white">${w.activity_type}</h4>
|
|
<span class="text-xs text-gray-400">${date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}</span>
|
|
</div>
|
|
<div class="flex items-center gap-4 mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
<span><i class="fas fa-clock mr-1"></i>${duration}</span>
|
|
${w.distance_meters > 0 ? `<span><i class="fas fa-route mr-1"></i>${distance} km</span>` : ''}
|
|
<span><i class="fas fa-fire mr-1"></i>${Math.round(w.calories)} kcal</span>
|
|
</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-xs text-gray-400">${date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
if (!seconds) return '0m';
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
if (h > 0) return `${h}h ${m}m`;
|
|
return `${m}m`;
|
|
}
|
|
|
|
function getActivityIcon(type) {
|
|
const icons = {
|
|
'Running': 'fas fa-person-running',
|
|
'Walking': 'fas fa-person-walking',
|
|
'Cycling': 'fas fa-bicycle',
|
|
'Elliptical': 'fas fa-heart-pulse',
|
|
'Free Training': 'fas fa-dumbbell',
|
|
'HIIT': 'fas fa-bolt',
|
|
'Stretching': 'fas fa-child-reaching',
|
|
'Yoga': 'fas fa-spa'
|
|
};
|
|
return icons[type] || 'fas fa-dumbbell';
|
|
}
|
|
|
|
function getActivityColor(type) {
|
|
const colors = {
|
|
'Running': 'bg-gradient-to-br from-orange-500 to-red-500',
|
|
'Walking': 'bg-gradient-to-br from-green-500 to-emerald-500',
|
|
'Cycling': 'bg-gradient-to-br from-blue-500 to-cyan-500',
|
|
'Elliptical': 'bg-gradient-to-br from-purple-500 to-pink-500',
|
|
'Free Training': 'bg-gradient-to-br from-gray-600 to-gray-700',
|
|
'HIIT': 'bg-gradient-to-br from-red-500 to-rose-600',
|
|
'Stretching': 'bg-gradient-to-br from-teal-500 to-cyan-500',
|
|
'Yoga': 'bg-gradient-to-br from-indigo-500 to-purple-500'
|
|
};
|
|
return colors[type] || 'bg-gradient-to-br from-gray-500 to-gray-600';
|
|
}
|
|
|
|
// --- Period-Specific Workout Renderers ---
|
|
function renderWorkoutsDay() {
|
|
const workouts = workoutState.workouts || [];
|
|
const targetDate = state.currentDate || new Date();
|
|
const today = targetDate.toISOString().split('T')[0];
|
|
const todayWorkouts = workouts.filter(w => {
|
|
const wDate = new Date(w.start_time).toISOString().split('T')[0];
|
|
return wDate === today;
|
|
});
|
|
|
|
// Update timeline
|
|
const timeline = document.getElementById('workout-timeline');
|
|
if (timeline) {
|
|
if (todayWorkouts.length === 0) {
|
|
timeline.innerHTML = `
|
|
<div class="text-center text-gray-400 py-8">
|
|
<i class="fas fa-calendar-day text-4xl mb-2"></i>
|
|
<p>No workouts recorded today</p>
|
|
</div>
|
|
`;
|
|
} else {
|
|
timeline.innerHTML = todayWorkouts.map(w => {
|
|
const date = new Date(w.start_time);
|
|
const duration = formatDuration(w.duration_seconds);
|
|
const distance = (w.distance_meters / 1000).toFixed(2);
|
|
const icon = getActivityIcon(w.activity_type);
|
|
const color = getActivityColor(w.activity_type);
|
|
|
|
return `
|
|
<div class="flex items-start gap-4 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-lg">
|
|
<div class="w-12 h-12 rounded-xl ${color} flex items-center justify-center text-white shadow-md flex-shrink-0">
|
|
<i class="${icon} text-lg"></i>
|
|
</div>
|
|
<div class="flex-1">
|
|
<div class="flex justify-between items-start">
|
|
<div>
|
|
<h4 class="font-bold text-gray-900 dark:text-white">${w.activity_type}</h4>
|
|
<p class="text-sm text-gray-500">${date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}</p>
|
|
</div>
|
|
<span class="text-xs text-gray-400">${duration}</span>
|
|
</div>
|
|
<div class="flex gap-4 mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
${w.distance_meters > 0 ? `<span><i class="fas fa-route mr-1"></i>${distance} km</span>` : ''}
|
|
<span><i class="fas fa-fire mr-1"></i>${Math.round(w.calories)} kcal</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
// Update summary cards
|
|
const totalDuration = todayWorkouts.reduce((sum, w) => sum + (w.duration_seconds || 0), 0);
|
|
const totalDistance = todayWorkouts.reduce((sum, w) => sum + (w.distance_meters || 0), 0);
|
|
const totalCalories = todayWorkouts.reduce((sum, w) => sum + (w.calories || 0), 0);
|
|
|
|
const countEl = document.getElementById('day-workout-count');
|
|
const durationEl = document.getElementById('day-workout-duration');
|
|
const distanceEl = document.getElementById('day-workout-distance');
|
|
const caloriesEl = document.getElementById('day-workout-calories');
|
|
|
|
if (countEl) countEl.innerText = todayWorkouts.length;
|
|
if (durationEl) durationEl.innerText = formatDuration(totalDuration);
|
|
if (distanceEl) distanceEl.innerText = `${(totalDistance / 1000).toFixed(1)} km`;
|
|
if (caloriesEl) caloriesEl.innerText = Math.round(totalCalories);
|
|
}
|
|
|
|
function renderWorkoutsWeek() {
|
|
// Week view is already rendered by renderWorkoutsTab
|
|
// Just ensure the data is visible
|
|
const summary = workoutState.summary;
|
|
if (!summary) return;
|
|
|
|
// Render intensity distribution (mock data for now)
|
|
const lowEl = document.getElementById('week-low-intensity');
|
|
const modEl = document.getElementById('week-mod-intensity');
|
|
const highEl = document.getElementById('week-high-intensity');
|
|
|
|
if (lowEl) lowEl.innerText = '45%';
|
|
if (modEl) modEl.innerText = '35%';
|
|
if (highEl) highEl.innerText = '20%';
|
|
}
|
|
|
|
function renderWorkoutsMonth() {
|
|
const workouts = workoutState.workouts || [];
|
|
const targetDate = state.currentDate || new Date();
|
|
const firstDayOfMonth = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1);
|
|
const lastDayOfMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0);
|
|
|
|
// Filter workouts for current month
|
|
const monthWorkouts = workouts.filter(w => {
|
|
const wDate = new Date(w.start_time);
|
|
return wDate >= firstDayOfMonth && wDate <= lastDayOfMonth;
|
|
});
|
|
|
|
// Calculate monthly totals
|
|
const totalSessions = monthWorkouts.length;
|
|
const totalDuration = monthWorkouts.reduce((sum, w) => sum + (w.duration_seconds || 0), 0);
|
|
const totalDistance = monthWorkouts.reduce((sum, w) => sum + (w.distance_meters || 0), 0);
|
|
const totalCalories = monthWorkouts.reduce((sum, w) => sum + (w.calories || 0), 0);
|
|
|
|
// Update summary cards
|
|
const sessionsEl = document.getElementById('month-total-sessions');
|
|
const distanceEl = document.getElementById('month-total-distance');
|
|
const caloriesEl = document.getElementById('month-total-calories');
|
|
const volumeEl = document.getElementById('month-total-volume');
|
|
|
|
if (sessionsEl) sessionsEl.innerText = totalSessions;
|
|
if (distanceEl) distanceEl.innerText = `${(totalDistance / 1000).toFixed(1)} km`;
|
|
if (caloriesEl) caloriesEl.innerText = Math.round(totalCalories);
|
|
if (volumeEl) volumeEl.innerText = `${(totalDuration / 3600).toFixed(1)} hrs`;
|
|
|
|
// Render cumulative volume chart
|
|
renderCumulativeVolumeChart(monthWorkouts);
|
|
|
|
// Render activity calendar
|
|
renderWorkoutCalendar(monthWorkouts);
|
|
}
|
|
|
|
function renderCumulativeVolumeChart(monthWorkouts) {
|
|
const ctx = document.getElementById('chart-cumulative-volume');
|
|
if (!ctx) return;
|
|
|
|
// Destroy existing chart
|
|
if (workoutState.charts.cumulative) {
|
|
workoutState.charts.cumulative.destroy();
|
|
}
|
|
|
|
const targetDate = state.currentDate || new Date();
|
|
const daysInMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate();
|
|
|
|
// Create cumulative data
|
|
const cumulativeData = [];
|
|
let cumulative = 0;
|
|
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const dayDate = new Date(targetDate.getFullYear(), targetDate.getMonth(), day);
|
|
const dayStr = dayDate.toISOString().split('T')[0];
|
|
|
|
const dayWorkouts = monthWorkouts.filter(w => {
|
|
return new Date(w.start_time).toISOString().split('T')[0] === dayStr;
|
|
});
|
|
|
|
const dayDuration = dayWorkouts.reduce((sum, w) => sum + (w.duration_seconds || 0), 0);
|
|
cumulative += dayDuration / 3600; // Convert to hours
|
|
cumulativeData.push(cumulative);
|
|
}
|
|
|
|
const labels = Array.from({ length: daysInMonth }, (_, i) => i + 1);
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
|
|
workoutState.charts.cumulative = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'This Month',
|
|
data: cumulativeData,
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
fill: true,
|
|
tension: 0.4,
|
|
borderWidth: 2
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
grid: { color: isDark ? '#374151' : '#e5e7eb' },
|
|
ticks: {
|
|
color: isDark ? '#d1d5db' : '#374151',
|
|
callback: (value) => value + 'h'
|
|
}
|
|
},
|
|
x: {
|
|
grid: { display: false },
|
|
ticks: { color: isDark ? '#d1d5db' : '#374151' }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderWorkoutCalendar(monthWorkouts) {
|
|
const calendar = document.getElementById('workout-calendar');
|
|
if (!calendar) return;
|
|
|
|
const targetDate = state.currentDate || new Date();
|
|
const today = new Date();
|
|
const firstDay = new Date(targetDate.getFullYear(), targetDate.getMonth(), 1);
|
|
const lastDay = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0);
|
|
const daysInMonth = lastDay.getDate();
|
|
const startingDayOfWeek = firstDay.getDay();
|
|
|
|
// Create calendar grid
|
|
let html = '';
|
|
|
|
// Day headers
|
|
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
dayNames.forEach(day => {
|
|
html += `<div class="text-center text-xs font-bold text-gray-400 p-2">${day}</div>`;
|
|
});
|
|
|
|
// Empty cells before first day
|
|
for (let i = 0; i < startingDayOfWeek; i++) {
|
|
html += `<div class="p-2"></div>`;
|
|
}
|
|
|
|
// Days of month
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
const dayDate = new Date(targetDate.getFullYear(), targetDate.getMonth(), day);
|
|
const dayStr = dayDate.toISOString().split('T')[0];
|
|
|
|
const dayWorkouts = monthWorkouts.filter(w => {
|
|
return new Date(w.start_time).toISOString().split('T')[0] === dayStr;
|
|
});
|
|
|
|
const hasWorkout = dayWorkouts.length > 0;
|
|
const isToday = dayDate.toDateString() === today.toDateString();
|
|
|
|
let cellClass = 'p-2 text-center rounded-lg text-sm ';
|
|
if (isToday) {
|
|
cellClass += 'ring-2 ring-brand-500 ';
|
|
}
|
|
if (hasWorkout) {
|
|
cellClass += 'bg-green-500 text-white font-bold';
|
|
} else {
|
|
cellClass += 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400';
|
|
}
|
|
|
|
html += `<div class="${cellClass}" title="${dayWorkouts.length} workout(s)">${day}</div>`;
|
|
}
|
|
|
|
calendar.innerHTML = html;
|
|
}
|