VO2 max carry-forward and sync lookback days fix
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:
@@ -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 (
|
if (!day) return (
|
||||||
<div className="text-center py-10 text-gray-600">
|
<div className="text-center py-10 text-gray-600">
|
||||||
<p className="text-3xl mb-2">📊</p>
|
<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>
|
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-2xl font-bold text-blue-400">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
|
{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)
|
return allDaysSorted.findIndex(d => d.date === selectedDay.date)
|
||||||
}, [selectedDay, allDaysSorted])
|
}, [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({
|
const { data: intradayData } = useQuery({
|
||||||
queryKey: ['health-intraday', selectedDay?.date],
|
queryKey: ['health-intraday', selectedDay?.date],
|
||||||
queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data),
|
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}
|
bbHires={intradayData?.body_battery_hires}
|
||||||
sleepStages={intradayData?.sleep_stages}
|
sleepStages={intradayData?.sleep_stages}
|
||||||
activities={dayActivities}
|
activities={dayActivities}
|
||||||
|
latestVo2max={latestVo2max}
|
||||||
onOlder={goOlder}
|
onOlder={goOlder}
|
||||||
onNewer={goNewer}
|
onNewer={goNewer}
|
||||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||||
|
|||||||
@@ -120,9 +120,11 @@ export default function ProfilePage() {
|
|||||||
const [gcError, setGcError] = useState('')
|
const [gcError, setGcError] = useState('')
|
||||||
const [gcSyncing, setGcSyncing] = useState(false)
|
const [gcSyncing, setGcSyncing] = useState(false)
|
||||||
const syncPollRef = useRef(null)
|
const syncPollRef = useRef(null)
|
||||||
|
const gcFormLoaded = useRef(false)
|
||||||
useEffect(() => () => { if (syncPollRef.current) clearInterval(syncPollRef.current) }, [])
|
useEffect(() => () => { if (syncPollRef.current) clearInterval(syncPollRef.current) }, [])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (garminConfig?.connected) {
|
if (garminConfig?.connected && !gcFormLoaded.current) {
|
||||||
|
gcFormLoaded.current = true
|
||||||
setGcForm(f => ({
|
setGcForm(f => ({
|
||||||
...f,
|
...f,
|
||||||
email: garminConfig.email || '',
|
email: garminConfig.email || '',
|
||||||
@@ -131,6 +133,8 @@ export default function ProfilePage() {
|
|||||||
sync_wellness: garminConfig.sync_wellness,
|
sync_wellness: garminConfig.sync_wellness,
|
||||||
sync_lookback_days: String(garminConfig.sync_lookback_days ?? 30),
|
sync_lookback_days: String(garminConfig.sync_lookback_days ?? 30),
|
||||||
}))
|
}))
|
||||||
|
} else if (!garminConfig?.connected) {
|
||||||
|
gcFormLoaded.current = false
|
||||||
}
|
}
|
||||||
}, [garminConfig])
|
}, [garminConfig])
|
||||||
const saveGarmin = useMutation({
|
const saveGarmin = useMutation({
|
||||||
|
|||||||
@@ -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 (
|
if (!day) return (
|
||||||
<div className="text-center py-10 text-gray-600">
|
<div className="text-center py-10 text-gray-600">
|
||||||
<p className="text-3xl mb-2">📊</p>
|
<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>
|
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-2xl font-bold text-blue-400">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
|
{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)
|
return allDaysSorted.findIndex(d => d.date === selectedDay.date)
|
||||||
}, [selectedDay, allDaysSorted])
|
}, [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({
|
const { data: intradayData } = useQuery({
|
||||||
queryKey: ['health-intraday', selectedDay?.date],
|
queryKey: ['health-intraday', selectedDay?.date],
|
||||||
queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data),
|
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}
|
bbHires={intradayData?.body_battery_hires}
|
||||||
sleepStages={intradayData?.sleep_stages}
|
sleepStages={intradayData?.sleep_stages}
|
||||||
activities={dayActivities}
|
activities={dayActivities}
|
||||||
|
latestVo2max={latestVo2max}
|
||||||
onOlder={goOlder}
|
onOlder={goOlder}
|
||||||
onNewer={goNewer}
|
onNewer={goNewer}
|
||||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||||
|
|||||||
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { useAuthStore } from '../hooks/useAuth'
|
import { useAuthStore } from '../hooks/useAuth'
|
||||||
@@ -56,13 +56,31 @@ export default function ProfilePage() {
|
|||||||
enabled: !!user?.is_admin,
|
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
|
// 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 [hrSaved, setHrSaved] = useState(false)
|
||||||
|
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
|
||||||
|
const maxHrChangedRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profile) setHrForm({
|
if (profile) setHrForm({
|
||||||
max_heart_rate: profile.max_heart_rate || '',
|
max_heart_rate: profile.max_heart_rate || '',
|
||||||
resting_heart_rate: profile.resting_heart_rate || '',
|
|
||||||
birth_year: profile.birth_year || '',
|
birth_year: profile.birth_year || '',
|
||||||
height_cm: profile.height_cm || '',
|
height_cm: profile.height_cm || '',
|
||||||
})
|
})
|
||||||
@@ -70,23 +88,16 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
const updateProfile = useMutation({
|
const updateProfile = useMutation({
|
||||||
mutationFn: data => api.patch('/profile/', data).then(r => r.data),
|
mutationFn: data => api.patch('/profile/', data).then(r => r.data),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) },
|
onSuccess: () => {
|
||||||
})
|
qc.invalidateQueries({ queryKey: ['profile'] })
|
||||||
|
setHrSaved(true)
|
||||||
// Weight log
|
setTimeout(() => setHrSaved(false), 3000)
|
||||||
const { data: weightLog } = useQuery({
|
if (maxHrChangedRef.current) {
|
||||||
queryKey: ['weight-log'],
|
setHrZoneRecalc(true)
|
||||||
queryFn: () => api.get('/profile/weight').then(r => r.data),
|
setTimeout(() => setHrZoneRecalc(false), 6000)
|
||||||
})
|
maxHrChangedRef.current = false
|
||||||
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'] }),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Password change
|
// Password change
|
||||||
@@ -99,6 +110,99 @@ export default function ProfilePage() {
|
|||||||
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
|
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
|
// PocketID config
|
||||||
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
|
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '' })
|
||||||
const [pidSaved, setPidSaved] = useState(false)
|
const [pidSaved, setPidSaved] = useState(false)
|
||||||
@@ -134,10 +238,6 @@ export default function ProfilePage() {
|
|||||||
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
|
||||||
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Resting heart rate (bpm)" hint="First thing in the morning">
|
|
||||||
<Input type="number" value={hrForm.resting_heart_rate} placeholder="e.g. 52" min={20} max={120}
|
|
||||||
onChange={e => setHrForm(f => ({ ...f, resting_heart_rate: e.target.value }))} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Birth year" hint="Used to estimate max HR if not set above">
|
<Field label="Birth year" hint="Used to estimate max HR if not set above">
|
||||||
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
|
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
|
||||||
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
|
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
|
||||||
@@ -148,57 +248,36 @@ export default function ProfilePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(avgRestingHr || healthSummary?.latest?.weight_kg) && (
|
||||||
|
<div className="flex gap-6 pt-3 border-t border-gray-800">
|
||||||
|
{avgRestingHr && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Resting HR (7-day avg, from Garmin)</p>
|
||||||
|
<span className="text-lg font-semibold text-rose-400">{avgRestingHr} bpm</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{healthSummary?.latest?.weight_kg && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 mb-0.5">Weight (from Garmin)</p>
|
||||||
|
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<SaveButton
|
<SaveButton
|
||||||
onClick={() => updateProfile.mutate(Object.fromEntries(
|
onClick={() => {
|
||||||
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
|
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}
|
loading={updateProfile.isPending}
|
||||||
saved={hrSaved}
|
saved={hrSaved}
|
||||||
/>
|
/>
|
||||||
</Section>
|
{hrZoneRecalc && (
|
||||||
|
<p className="text-xs text-blue-400 mt-1">HR zones are being recalculated for your existing activities.</p>
|
||||||
{/* Weight log */}
|
|
||||||
<Section title="Weight Log">
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<Field label="Weight (kg)">
|
|
||||||
<Input type="number" value={weightForm.weight_kg} placeholder="75.5" min={20} max={500}
|
|
||||||
onChange={e => setWeightForm(f => ({ ...f, weight_kg: e.target.value }))} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Body fat % (optional)">
|
|
||||||
<Input type="number" value={weightForm.body_fat_pct} placeholder="18.5" min={1} max={70}
|
|
||||||
onChange={e => setWeightForm(f => ({ ...f, body_fat_pct: e.target.value }))} />
|
|
||||||
</Field>
|
|
||||||
<Field label="Date">
|
|
||||||
<Input type="datetime-local" value={weightForm.date}
|
|
||||||
onChange={e => setWeightForm(f => ({ ...f, date: e.target.value }))} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<SaveButton
|
|
||||||
onClick={() => 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 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="text-xs text-gray-500 mb-2">Recent entries</p>
|
|
||||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
||||||
{weightLog.slice(0, 20).map(entry => (
|
|
||||||
<div key={entry.id} className="flex items-center justify-between py-1.5 border-b border-gray-800/50 text-sm">
|
|
||||||
<span className="text-gray-500 text-xs">{new Date(entry.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
|
||||||
<span className="text-white font-medium">{entry.weight_kg.toFixed(1)} kg</span>
|
|
||||||
{entry.body_fat_pct && <span className="text-gray-400 text-xs">{entry.body_fat_pct.toFixed(1)}% fat</span>}
|
|
||||||
<button onClick={() => deleteWeight.mutate(entry.id)}
|
|
||||||
className="text-gray-700 hover:text-red-400 text-xs transition-colors">✕</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -230,6 +309,133 @@ export default function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Garmin Connect Sync */}
|
||||||
|
<Section title="⌚ Garmin Connect Sync">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Connect your Garmin account to automatically import new activities and wellness data every hour.
|
||||||
|
Credentials are encrypted at rest.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{garminConfig?.connected && (
|
||||||
|
<div className="flex items-center justify-between bg-green-900/20 border border-green-800/40 rounded-lg px-3 py-2 text-xs">
|
||||||
|
<span className="text-green-400">✓ Connected as {garminConfig.email}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{garminConfig.last_sync_at && (
|
||||||
|
<span className="text-gray-500">
|
||||||
|
Last sync: {new Date(garminConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{garminConfig.last_sync_status && (
|
||||||
|
<span className={garminConfig.last_sync_status.startsWith('OK') ? 'text-green-400' : garminConfig.last_sync_status.startsWith('Auth') ? 'text-red-400' : 'text-yellow-400'}>
|
||||||
|
{garminConfig.last_sync_status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Garmin Connect email">
|
||||||
|
<Input value={gcForm.email} placeholder="you@example.com"
|
||||||
|
onChange={e => setGcForm(f => ({ ...f, email: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label={garminConfig?.connected ? 'Password (leave blank to keep existing)' : 'Password'}>
|
||||||
|
<Input type="password" value={gcForm.password} placeholder="••••••••"
|
||||||
|
onChange={e => setGcForm(f => ({ ...f, password: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 pt-1">
|
||||||
|
{[
|
||||||
|
['sync_enabled', 'Enable hourly sync'],
|
||||||
|
['sync_activities', 'Sync activities (FIT download)'],
|
||||||
|
['sync_wellness', 'Sync wellness data'],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<label key={key} className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={gcForm[key]}
|
||||||
|
onChange={e => setGcForm(f => ({ ...f, [key]: e.target.checked }))}
|
||||||
|
className="w-4 h-4 accent-blue-500" />
|
||||||
|
<span className="text-sm text-gray-300">{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Sync lookback days" hint="-1 syncs all available history (back to 2010). Leave at 30 for incremental syncs.">
|
||||||
|
<Input type="number" value={gcForm.sync_lookback_days} min={-1}
|
||||||
|
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
|
||||||
|
{(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && (
|
||||||
|
<p className="text-yellow-400 text-xs mt-1">Warning: syncing more than 365 days at once may take a long time and could trigger Garmin rate limits.</p>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gcError && <p className="text-red-400 text-xs">{gcError}</p>}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap pt-1">
|
||||||
|
<SaveButton
|
||||||
|
onClick={() => {
|
||||||
|
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 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={triggerGarminSync}
|
||||||
|
disabled={gcSyncing}
|
||||||
|
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
|
||||||
|
{gcSyncing ? 'Syncing…' : '↻ Sync now'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm('Remove Garmin Connect credentials?')) deleteGarmin.mutate() }}
|
||||||
|
className="text-red-400 hover:text-red-300 text-sm transition-colors">
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
{[['Connect', 0], ['Activities', 1], ['Wellness', 2]].map(([label, idx]) => (
|
||||||
|
<span key={label} className={`flex items-center gap-1 ${phase >= idx ? 'text-blue-400' : 'text-gray-600'}`}>
|
||||||
|
{idx > 0 && <span className="text-gray-700">›</span>}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 rounded-full transition-all duration-700"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-blue-400">
|
||||||
|
{status || 'Starting sync…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* PocketID — admin only */}
|
{/* PocketID — admin only */}
|
||||||
{user?.is_admin && (
|
{user?.is_admin && (
|
||||||
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
<Section title="🔑 PocketID Passkey Authentication (Admin)">
|
||||||
|
|||||||
Reference in New Issue
Block a user