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, sleepStag

VO2 Max

- {day.vo2max ? day.vo2max.toFixed(1) : '--'} + {(day.vo2max ?? latestVo2max) != null ? (day.vo2max ?? latestVo2max).toFixed(1) : '--'}
{day.fitness_age &&

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, sleepStag

VO2 Max

- {day.vo2max ? day.vo2max.toFixed(1) : '--'} + {(day.vo2max ?? latestVo2max) != null ? (day.vo2max ?? latestVo2max).toFixed(1) : '--'}
{day.fitness_age &&

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 }))} /> - - setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} /> - setHrForm(f => ({ ...f, birth_year: e.target.value }))} /> @@ -148,57 +248,36 @@ export default function ProfilePage() {
+ {(avgRestingHr || healthSummary?.latest?.weight_kg) && ( +
+ {avgRestingHr && ( +
+

Resting HR (7-day avg, from Garmin)

+ {avgRestingHr} bpm +
+ )} + {healthSummary?.latest?.weight_kg && ( +
+

Weight (from Garmin)

+ {healthSummary.latest.weight_kg.toFixed(1)} kg +
+ )} +
+ )} + updateProfile.mutate(Object.fromEntries( - Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)]) - ))} + onClick={() => { + const data = Object.fromEntries( + Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)]) + ) + maxHrChangedRef.current = data.max_heart_rate !== undefined && data.max_heart_rate !== profile?.max_heart_rate + updateProfile.mutate(data) + }} loading={updateProfile.isPending} saved={hrSaved} /> - - - {/* Weight log */} -
-
- - setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} /> - - - setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} /> - - - setWeightForm(f => ({ ...f, date: e.target.value }))} /> - -
- addWeight.mutate({ - weight_kg: parseFloat(weightForm.weight_kg), - body_fat_pct: weightForm.body_fat_pct ? parseFloat(weightForm.body_fat_pct) : null, - date: new Date(weightForm.date).toISOString(), - })} - loading={addWeight.isPending} - saved={weightSaved} - label="Log weight" - /> - - {weightLog && weightLog.length > 0 && ( -
-

Recent entries

-
- {weightLog.slice(0, 20).map(entry => ( -
- {new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })} - {entry.weight_kg.toFixed(1)} kg - {entry.body_fat_pct && {entry.body_fat_pct.toFixed(1)}% fat} - -
- ))} -
-
+ {hrZoneRecalc && ( +

HR zones are being recalculated for your existing activities.

)}
@@ -230,6 +309,133 @@ export default function ProfilePage() { /> + {/* Garmin Connect Sync */} +
+

+ Connect your Garmin account to automatically import new activities and wellness data every hour. + Credentials are encrypted at rest. +

+ + {garminConfig?.connected && ( +
+ ✓ Connected as {garminConfig.email} +
+ {garminConfig.last_sync_at && ( + + Last sync: {new Date(garminConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} + + )} + {garminConfig.last_sync_status && ( + + {garminConfig.last_sync_status} + + )} +
+
+ )} + +
+ + setGcForm(f => ({ ...f, email: e.target.value }))} /> + + + setGcForm(f => ({ ...f, password: e.target.value }))} /> + + +
+ {[ + ['sync_enabled', 'Enable hourly sync'], + ['sync_activities', 'Sync activities (FIT download)'], + ['sync_wellness', 'Sync wellness data'], + ].map(([key, label]) => ( + + ))} +
+ + + setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} /> + {(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && ( +

Warning: syncing more than 365 days at once may take a long time and could trigger Garmin rate limits.

+ )} +
+
+ + {gcError &&

{gcError}

} + +
+ { + if (!garminConfig?.connected && !gcForm.password) { + setGcError('Password is required for first-time setup') + return + } + const payload = { + ...gcForm, + sync_lookback_days: parseInt(gcForm.sync_lookback_days, 10) || 30, + } + if (!payload.password) delete payload.password + saveGarmin.mutate(payload) + }} + loading={saveGarmin.isPending} + saved={gcSaved} + label={garminConfig?.connected ? 'Update' : 'Connect'} + /> + {garminConfig?.connected && ( + <> + + + + )} +
+ + {gcSyncing && (() => { + const status = garminConfig?.last_sync_status || '' + const pct = syncProgressPct(status) + const phase = status.startsWith('Connecting') ? 0 + : status.startsWith('Syncing activities') ? 1 + : status.startsWith('Syncing wellness') ? 2 + : status.startsWith('OK') || status.startsWith('Partial') ? 3 : -1 + return ( +
+
+ {[['Connect', 0], ['Activities', 1], ['Wellness', 2]].map(([label, idx]) => ( + = idx ? 'text-blue-400' : 'text-gray-600'}`}> + {idx > 0 && ›} + {label} + + ))} +
+
+
+
+

+ {status || 'Starting sync…'} +

+
+ ) + })()} +
+ {/* PocketID — admin only */} {user?.is_admin && (