diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 95a1f8b..ae1a002 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -304,7 +304,7 @@ function NavArrow({ onClick, disabled, children }) { ) } -function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, onOlder, onNewer, hasOlder, hasNewer }) { +function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, onOlder, onNewer, hasOlder, hasNewer }) { if (!day) return (
📊
@@ -523,7 +523,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStagVO2 Max
Fitness age {day.fitness_age}
} @@ -673,6 +673,12 @@ export default function HealthPage() { return allDaysSorted.findIndex(d => d.date === selectedDay.date) }, [selectedDay, allDaysSorted]) + // Most recent day with a VO2 max reading (Garmin only updates it after certain activities) + const latestVo2max = useMemo(() => { + const found = allDaysSorted.find(d => d.vo2max != null) + return found ? found.vo2max : null + }, [allDaysSorted]) + const { data: intradayData } = useQuery({ queryKey: ['health-intraday', selectedDay?.date], queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data), @@ -714,6 +720,7 @@ export default function HealthPage() { bbHires={intradayData?.body_battery_hires} sleepStages={intradayData?.sleep_stages} activities={dayActivities} + latestVo2max={latestVo2max} onOlder={goOlder} onNewer={goNewer} hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1} diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx index ab70688..a8f1c8d 100644 --- a/frontend/src/pages/ProfilePage.jsx +++ b/frontend/src/pages/ProfilePage.jsx @@ -120,9 +120,11 @@ export default function ProfilePage() { const [gcError, setGcError] = useState('') const [gcSyncing, setGcSyncing] = useState(false) const syncPollRef = useRef(null) + const gcFormLoaded = useRef(false) useEffect(() => () => { if (syncPollRef.current) clearInterval(syncPollRef.current) }, []) useEffect(() => { - if (garminConfig?.connected) { + if (garminConfig?.connected && !gcFormLoaded.current) { + gcFormLoaded.current = true setGcForm(f => ({ ...f, email: garminConfig.email || '', @@ -131,6 +133,8 @@ export default function ProfilePage() { sync_wellness: garminConfig.sync_wellness, sync_lookback_days: String(garminConfig.sync_lookback_days ?? 30), })) + } else if (!garminConfig?.connected) { + gcFormLoaded.current = false } }, [garminConfig]) const saveGarmin = useMutation({ diff --git a/milevault_export/frontend/src/pages/HealthPage.jsx b/milevault_export/frontend/src/pages/HealthPage.jsx index 95a1f8b..ae1a002 100644 --- a/milevault_export/frontend/src/pages/HealthPage.jsx +++ b/milevault_export/frontend/src/pages/HealthPage.jsx @@ -304,7 +304,7 @@ function NavArrow({ onClick, disabled, children }) { ) } -function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, onOlder, onNewer, hasOlder, hasNewer }) { +function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, onOlder, onNewer, hasOlder, hasNewer }) { if (!day) return (📊
@@ -523,7 +523,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStagVO2 Max
Fitness age {day.fitness_age}
} @@ -673,6 +673,12 @@ export default function HealthPage() { return allDaysSorted.findIndex(d => d.date === selectedDay.date) }, [selectedDay, allDaysSorted]) + // Most recent day with a VO2 max reading (Garmin only updates it after certain activities) + const latestVo2max = useMemo(() => { + const found = allDaysSorted.find(d => d.vo2max != null) + return found ? found.vo2max : null + }, [allDaysSorted]) + const { data: intradayData } = useQuery({ queryKey: ['health-intraday', selectedDay?.date], queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data), @@ -714,6 +720,7 @@ export default function HealthPage() { bbHires={intradayData?.body_battery_hires} sleepStages={intradayData?.sleep_stages} activities={dayActivities} + latestVo2max={latestVo2max} onOlder={goOlder} onNewer={goNewer} hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1} diff --git a/milevault_export/frontend/src/pages/ProfilePage.jsx b/milevault_export/frontend/src/pages/ProfilePage.jsx index 0b168b1..a8f1c8d 100644 --- a/milevault_export/frontend/src/pages/ProfilePage.jsx +++ b/milevault_export/frontend/src/pages/ProfilePage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' import { useAuthStore } from '../hooks/useAuth' @@ -56,13 +56,31 @@ export default function ProfilePage() { enabled: !!user?.is_admin, }) + const { data: recentMetrics } = useQuery({ + queryKey: ['health-metrics-recent'], + queryFn: () => api.get('/health-metrics/', { params: { limit: 7 } }).then(r => r.data), + }) + + const { data: healthSummary } = useQuery({ + queryKey: ['health-summary'], + queryFn: () => api.get('/health-metrics/summary').then(r => r.data), + }) + + const avgRestingHr = useMemo(() => { + if (!recentMetrics?.length) return null + const vals = recentMetrics.filter(m => m.resting_hr != null).map(m => m.resting_hr) + if (!vals.length) return null + return Math.round(vals.reduce((s, v) => s + v, 0) / vals.length) + }, [recentMetrics]) + // HR / measurements form - const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' }) + const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '' }) const [hrSaved, setHrSaved] = useState(false) + const [hrZoneRecalc, setHrZoneRecalc] = useState(false) + const maxHrChangedRef = useRef(false) useEffect(() => { if (profile) setHrForm({ max_heart_rate: profile.max_heart_rate || '', - resting_heart_rate: profile.resting_heart_rate || '', birth_year: profile.birth_year || '', height_cm: profile.height_cm || '', }) @@ -70,23 +88,16 @@ export default function ProfilePage() { const updateProfile = useMutation({ mutationFn: data => api.patch('/profile/', data).then(r => r.data), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) }, - }) - - // Weight log - const { data: weightLog } = useQuery({ - queryKey: ['weight-log'], - queryFn: () => api.get('/profile/weight').then(r => r.data), - }) - const [weightForm, setWeightForm] = useState({ weight_kg: '', body_fat_pct: '', date: new Date().toISOString().slice(0, 16) }) - const [weightSaved, setWeightSaved] = useState(false) - const addWeight = useMutation({ - mutationFn: data => api.post('/profile/weight', data).then(r => r.data), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['weight-log'] }); setWeightSaved(true); setTimeout(() => setWeightSaved(false), 3000); setWeightForm(f => ({ ...f, weight_kg: '', body_fat_pct: '' })) }, - }) - const deleteWeight = useMutation({ - mutationFn: id => api.delete(`/profile/weight/${id}`), - onSuccess: () => qc.invalidateQueries({ queryKey: ['weight-log'] }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['profile'] }) + setHrSaved(true) + setTimeout(() => setHrSaved(false), 3000) + if (maxHrChangedRef.current) { + setHrZoneRecalc(true) + setTimeout(() => setHrZoneRecalc(false), 6000) + maxHrChangedRef.current = false + } + }, }) // Password change @@ -99,6 +110,99 @@ export default function ProfilePage() { onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'), }) + // Garmin Connect sync + const { data: garminConfig, refetch: refetchGarmin } = useQuery({ + queryKey: ['garmin-config'], + queryFn: () => api.get('/garmin-sync/config').then(r => r.data), + }) + const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' }) + const [gcSaved, setGcSaved] = useState(false) + const [gcError, setGcError] = useState('') + const [gcSyncing, setGcSyncing] = useState(false) + const syncPollRef = useRef(null) + const gcFormLoaded = useRef(false) + useEffect(() => () => { if (syncPollRef.current) clearInterval(syncPollRef.current) }, []) + useEffect(() => { + if (garminConfig?.connected && !gcFormLoaded.current) { + gcFormLoaded.current = true + setGcForm(f => ({ + ...f, + email: garminConfig.email || '', + sync_enabled: garminConfig.sync_enabled, + sync_activities: garminConfig.sync_activities, + sync_wellness: garminConfig.sync_wellness, + sync_lookback_days: String(garminConfig.sync_lookback_days ?? 30), + })) + } else if (!garminConfig?.connected) { + gcFormLoaded.current = false + } + }, [garminConfig]) + const saveGarmin = useMutation({ + mutationFn: data => api.put('/garmin-sync/config', data).then(r => r.data), + onSuccess: () => { + refetchGarmin() + setGcSaved(true) + setGcError('') + setGcForm(f => ({ ...f, password: '' })) + setTimeout(() => setGcSaved(false), 3000) + }, + onError: e => setGcError(e.response?.data?.detail || 'Failed to save'), + }) + const deleteGarmin = useMutation({ + mutationFn: () => api.delete('/garmin-sync/config'), + onSuccess: () => { + refetchGarmin() + setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' }) + }, + }) + const triggerGarminSync = async () => { + setGcSyncing(true) + try { + await api.post('/garmin-sync/trigger') + // Poll every 3s: wait until we've seen an in-progress status, then wait for terminal + let seenInProgress = false + syncPollRef.current = setInterval(async () => { + const result = await refetchGarmin() + const status = result.data?.last_sync_status ?? '' + const terminal = status.startsWith('OK') || status.startsWith('Partial') || status.startsWith('Auth error') + if (!terminal) seenInProgress = true + if (seenInProgress && terminal) { + clearInterval(syncPollRef.current) + syncPollRef.current = null + setGcSyncing(false) + } + }, 3000) + // Absolute safety: stop polling after 4 hours but keep bar visible — sync may still be running + setTimeout(() => { + if (syncPollRef.current) { clearInterval(syncPollRef.current); syncPollRef.current = null } + }, 4 * 60 * 60 * 1000) + } catch { + setGcSyncing(false) + } + } + + const syncProgressPct = status => { + if (!status) return 3 + if (status.startsWith('Connecting')) return 10 + if (status.startsWith('Syncing activities')) { + const m = status.match(/(\d+)\/(\d+)/) + if (m) { + const done = parseInt(m[1], 10), total = parseInt(m[2], 10) + if (total > 0) return 15 + Math.round(done / total * 30) + } + return 20 + } + if (status.startsWith('Syncing wellness')) { + const m = status.match(/(\d+)\/(\d+)/) + if (m) { + const done = parseInt(m[1], 10), total = parseInt(m[2], 10) + if (total > 0) return 45 + Math.round(done / total * 45) + } + return 50 + } + return 3 + } + // PocketID config const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' }) const [pidSaved, setPidSaved] = useState(false) @@ -134,10 +238,6 @@ export default function ProfilePage() { setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} /> -Resting HR (7-day avg, from Garmin)
+ {avgRestingHr} bpm +Weight (from Garmin)
+ {healthSummary.latest.weight_kg.toFixed(1)} kg +Recent entries
-HR zones are being recalculated for your existing activities.
)}+ Connect your Garmin account to automatically import new activities and wellness data every hour. + Credentials are encrypted at rest. +
+ + {garminConfig?.connected && ( +Warning: syncing more than 365 days at once may take a long time and could trigger Garmin rate limits.
+ )} +{gcError}
} + ++ {status || 'Starting sync…'} +
+