VO2 max carry-forward and sync lookback days fix
Build and push images / validate (push) Successful in 19s
Build and push images / build-backend (push) Successful in 30s
Build and push images / build-worker (push) Successful in 1m12s
Build and push images / build-frontend (push) Successful in 48s

Show the most recently known VO2 max value on days where Garmin has
not produced a new estimate (it only updates after certain activities).
Fix the sync lookback days input resetting to the server value during
polling — the form now initialises from the server once on first load
and is not overwritten by background refetches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:12:51 +01:00
parent 45ff4c26aa
commit 13ed824f01
4 changed files with 300 additions and 76 deletions
+9 -2
View File
@@ -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 (
<div className="text-center py-10 text-gray-600">
<p className="text-3xl mb-2">📊</p>
@@ -523,7 +523,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-blue-400">
{day.vo2max ? day.vo2max.toFixed(1) : '--'}
{(day.vo2max ?? latestVo2max) != null ? (day.vo2max ?? latestVo2max).toFixed(1) : '--'}
</span>
</div>
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
@@ -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}
+5 -1
View File
@@ -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({