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