diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 4171c9e..91c7252 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -1,12 +1,12 @@ import { useState, useMemo } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useQuery, keepPreviousData } from '@tanstack/react-query' import { - AreaChart, Area, BarChart, Bar, + AreaChart, Area, BarChart, Bar, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts' import { format, subDays } from 'date-fns' import api from '../utils/api' -import { formatSleep, formatWeight, formatHeartRate } from '../utils/format' +import { formatSleep } from '../utils/format' const RANGES = [ { label: '1W', days: 7 }, @@ -17,9 +17,14 @@ const RANGES = [ { label: '1Y', days: 365 }, ] -const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 } +const tooltipStyle = { + background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, +} -// ── Daily Snapshot helpers ────────────────────────────────────────────────── +// Normalise any date string to YYYY-MM-DD so XAxis values and ReferenceLine x match. +const d10 = (s) => (s || '').slice(0, 10) + +// ── Daily Snapshot ────────────────────────────────────────────────────────── function fmtTime(ts) { if (!ts) return '--' @@ -43,31 +48,31 @@ function SleepStagesBar({ deep, light, rem, awake }) { function HrvBadge({ status }) { if (!status) return null const palette = { - balanced: 'text-green-400 bg-green-400/10 border-green-400/30', + balanced: 'text-green-400 bg-green-400/10 border-green-400/30', unbalanced: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30', - low: 'text-orange-400 bg-orange-400/10 border-orange-400/30', - poor: 'text-red-400 bg-red-400/10 border-red-400/30', + low: 'text-orange-400 bg-orange-400/10 border-orange-400/30', + poor: 'text-red-400 bg-red-400/10 border-red-400/30', } const cls = palette[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30' return {status} } -function Stat({ label, value, unit, sub, accent }) { - const accentCls = accent === 'red' ? 'text-rose-400' : accent === 'blue' ? 'text-blue-400' : accent === 'green' ? 'text-green-400' : 'text-white' +function NavArrow({ onClick, disabled, children }) { return ( -
-

{label}

-
- {value} - {unit && {unit}} -
- {sub &&

{sub}

} -
+ ) } -function DailySnapshot({ latest, avg30 }) { - if (!latest) return ( +function DailySnapshot({ day, avg30, onOlder, onNewer, hasOlder, hasNewer }) { + if (!day) return (

📊

No health data yet

@@ -75,71 +80,67 @@ function DailySnapshot({ latest, avg30 }) {
) - const dateLabel = latest.date - ? format(new Date(latest.date), 'EEEE, d MMMM yyyy') - : 'Latest' - - const hasSleepStages = latest.sleep_deep_s || latest.sleep_light_s || latest.sleep_rem_s + const dateLabel = day.date ? format(new Date(day.date), 'EEEE, d MMMM yyyy') : 'Latest' + const hasSleepStages = day.sleep_deep_s || day.sleep_light_s || day.sleep_rem_s const stepsGoal = 10000 - const stepsPct = latest.steps ? Math.min(100, (latest.steps / stepsGoal * 100).toFixed(0)) : 0 + const stepsPct = day.steps ? Math.min(100, Math.round(day.steps / stepsGoal * 100)) : 0 - const stressLabel = !latest.avg_stress ? null : - latest.avg_stress < 25 ? 'Restful' : - latest.avg_stress < 50 ? 'Low' : - latest.avg_stress < 75 ? 'Medium' : 'High' - const stressColor = !latest.avg_stress ? 'text-white' : - latest.avg_stress < 25 ? 'text-green-400' : - latest.avg_stress < 50 ? 'text-yellow-400' : - latest.avg_stress < 75 ? 'text-orange-400' : 'text-red-400' + const stressLabel = !day.avg_stress ? null + : day.avg_stress < 25 ? 'Restful' + : day.avg_stress < 50 ? 'Low' + : day.avg_stress < 75 ? 'Medium' : 'High' + const stressColor = !day.avg_stress ? 'text-white' + : day.avg_stress < 25 ? 'text-green-400' + : day.avg_stress < 50 ? 'text-yellow-400' + : day.avg_stress < 75 ? 'text-orange-400' : 'text-red-400' return (
-
-

Daily snapshot

-

{dateLabel}

+ + {/* Header + arrows */} +
+ +
+

Daily snapshot

+

{dateLabel}

+
+
- {/* Top row: Sleep (wide) + Heart/HRV */} + {/* Sleep (wide) + Heart / HRV */}
- {/* Sleep card — 2/3 width on desktop */}

Sleep

-
- {latest.sleep_score != null && ( - - Score {Math.round(latest.sleep_score)} - - )} -
-
- -
- - {formatSleep(latest.sleep_duration_s)} - - {latest.sleep_start && latest.sleep_end && ( - - {fmtTime(latest.sleep_start)} → {fmtTime(latest.sleep_end)} + {day.sleep_score != null && ( + + Score {Math.round(day.sleep_score)} + + )} +
+
+ + {formatSleep(day.sleep_duration_s)} + + {day.sleep_start && day.sleep_end && ( + + {fmtTime(day.sleep_start)} → {fmtTime(day.sleep_end)} )}
- {hasSleepStages ? ( <>
{[ - ['Deep', latest.sleep_deep_s, '#6366f1'], - ['REM', latest.sleep_rem_s, '#8b5cf6'], - ['Light', latest.sleep_light_s, '#a78bfa'], - ['Awake', latest.sleep_awake_s, '#4b5563'], + ['Deep', day.sleep_deep_s, '#6366f1'], + ['REM', day.sleep_rem_s, '#8b5cf6'], + ['Light', day.sleep_light_s, '#a78bfa'], + ['Awake', day.sleep_awake_s, '#4b5563'], ].map(([label, secs, color]) => secs ? (
@@ -150,53 +151,47 @@ function DailySnapshot({ latest, avg30 }) { ) : null)}
- ) : !latest.sleep_duration_s ? ( + ) : !day.sleep_duration_s ? (

No sleep data

) : null}
- {/* Heart & HRV card — 1/3 width */}

Heart & HRV

-

Resting HR

- {latest.resting_hr ? Math.round(latest.resting_hr) : '--'} + {day.resting_hr ? Math.round(day.resting_hr) : '--'} bpm
- {avg30?.resting_hr && latest.resting_hr && ( + {avg30?.resting_hr && day.resting_hr && (

30d avg {Math.round(avg30.resting_hr)} bpm - {latest.resting_hr < avg30.resting_hr + {day.resting_hr < avg30.resting_hr ? - : latest.resting_hr > avg30.resting_hr + : day.resting_hr > avg30.resting_hr ? : null}

)}
-

HRV

- {latest.hrv_nightly_avg ? Math.round(latest.hrv_nightly_avg) : '--'} + {day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'} ms - +
- - {latest.avg_hr_day && ( + {day.avg_hr_day && (

Avg HR (day)

- - {Math.round(latest.avg_hr_day)} - + {Math.round(day.avg_hr_day)} bpm
@@ -207,78 +202,69 @@ function DailySnapshot({ latest, avg30 }) { {/* Activity strip */}
- {/* Steps */}

Steps

- {latest.steps ? latest.steps.toLocaleString() : '--'} + {day.steps ? day.steps.toLocaleString() : '--'}
- {latest.steps ? ( + {day.steps ? ( <>
-
+

{stepsPct}% of {stepsGoal.toLocaleString()}

) : null} - {latest.floors_climbed ? ( -

{latest.floors_climbed} floors

- ) : null} + {day.floors_climbed + ?

{day.floors_climbed} floors

+ : null}
- {/* Calories */}

Calories

- {latest.total_calories - ? Math.round(latest.total_calories) - : latest.active_calories - ? Math.round(latest.active_calories) - : '--'} + {day.total_calories + ? Math.round(day.total_calories) + : day.active_calories ? Math.round(day.active_calories) : '--'} kcal
- {latest.active_calories && latest.total_calories && ( -

- Active {Math.round(latest.active_calories)} kcal -

+ {day.active_calories && day.total_calories && ( +

Active {Math.round(day.active_calories)} kcal

)}
- {/* Stress */}

Stress

- {latest.avg_stress ? Math.round(latest.avg_stress) : '--'} + {day.avg_stress ? Math.round(day.avg_stress) : '--'} - {latest.avg_stress && /100} + {day.avg_stress && /100}
{stressLabel &&

{stressLabel}

}
- {/* SpO2 or VO2 Max */}
- {latest.spo2_avg ? ( + {day.spo2_avg ? ( <>

SpO2

- {latest.spo2_avg.toFixed(1)} + {day.spo2_avg.toFixed(1)} %
- ) : latest.vo2max ? ( + ) : day.vo2max ? ( <>

VO2 Max

- {latest.vo2max.toFixed(1)} + {day.vo2max.toFixed(1)}
- {latest.fitness_age && ( -

Fitness age {latest.fitness_age}

- )} + {day.fitness_age &&

Fitness age {day.fitness_age}

} ) : ( <> @@ -294,14 +280,22 @@ function DailySnapshot({ latest, avg30 }) { // ── Trend Charts ──────────────────────────────────────────────────────────── -function MetricChart({ data, dataKey, color, formatter, height = 140 }) { +function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick }) { const vals = data.filter(d => d[dataKey] != null) if (!vals.length) return (
No data
) return ( - + { + const p = evt?.activePayload?.[0]?.payload + if (p?.date && onDayClick) onDayClick(p.date) + }} + > @@ -315,6 +309,9 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) { tickFormatter={formatter} /> format(new Date(d), 'MMM d, yyyy')} formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} /> + {selectedDate && ( + + )} @@ -322,27 +319,41 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) { ) } -function SleepChart({ data }) { +function SleepChart({ data, selectedDate, onDayClick }) { const chartData = data.map(d => ({ - date: d.date, - deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null, - rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null, + date: d.date, // already normalised to YYYY-MM-DD + deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null, + rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null, light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null, awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null, })) const hasData = chartData.some(d => d.deep || d.rem || d.light) - if (!hasData) return
No sleep data
+ if (!hasData) return ( +
No sleep data
+ ) return ( - + { + const p = evt?.activePayload?.[0]?.payload + if (p?.date && onDayClick) onDayClick(p.date) + }} + > format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> `${v}h`} /> format(new Date(d), 'MMM d, yyyy')} /> - - + {selectedDate && ( + + )} + + @@ -350,44 +361,92 @@ function SleepChart({ data }) { ) } -// ── Page ──────────────────────────────────────────────────────────────────── +// ── Page ───────────────────────────────────────────────────────────────────── export default function HealthPage() { const [rangeDays, setRangeDays] = useState(7) + const [selectedDateStr, setSelectedDateStr] = useState(null) // YYYY-MM-DD or null = latest - const fromDate = useMemo(() => format(subDays(new Date(), rangeDays), 'yyyy-MM-dd'), [rangeDays]) + const fromDate = useMemo( + () => format(subDays(new Date(), rangeDays), 'yyyy-MM-dd'), + [rangeDays], + ) const { data: summary } = useQuery({ queryKey: ['health-summary'], queryFn: () => api.get('/health-metrics/summary').then(r => r.data), }) - const { data: metrics, isLoading } = useQuery({ - queryKey: ['health-metrics', rangeDays], + // Full history for snapshot navigation. + // Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically. + const { data: allDays } = useQuery({ + queryKey: ['health-metrics', 'all'], queryFn: () => - api.get('/health-metrics/', { - params: { from_date: fromDate, limit: rangeDays + 1 }, - }).then(r => r.data.slice().reverse()), - keepPreviousData: true, + api.get('/health-metrics/', { params: { limit: 365 } }) + .then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))), }) - const latest = summary?.latest - const avg30 = summary?.avg_30d + // Trend window (changes with range selector). + // Dates normalised to YYYY-MM-DD so XAxis values match ReferenceLine x. + const { data: rawMetrics, isLoading } = useQuery({ + queryKey: ['health-metrics', rangeDays], + queryFn: () => + api.get('/health-metrics/', { params: { from_date: fromDate, limit: rangeDays + 1 } }) + .then(r => r.data.slice().reverse().map(d => ({ ...d, date: d10(d.date) }))), + placeholderData: keepPreviousData, + }) + const metrics = rawMetrics || [] + + // Snapshot navigation: newest-first sorted list of all available days + const allDaysSorted = useMemo( + () => (allDays || []).slice().sort((a, b) => b.date.localeCompare(a.date)), + [allDays], + ) + + const selectedDay = useMemo(() => { + if (!selectedDateStr) return allDaysSorted[0] || null + return allDaysSorted.find(d => d.date === selectedDateStr) || null + }, [selectedDateStr, allDaysSorted]) + + const selectedIdx = useMemo(() => { + if (!selectedDay) return -1 + return allDaysSorted.findIndex(d => d.date === selectedDay.date) + }, [selectedDay, allDaysSorted]) + + const handleDayClick = (dateStr) => setSelectedDateStr(d10(dateStr)) + const goOlder = () => { + if (selectedIdx < allDaysSorted.length - 1) + setSelectedDateStr(allDaysSorted[selectedIdx + 1].date) + } + const goNewer = () => { + if (selectedIdx > 0) + setSelectedDateStr(allDaysSorted[selectedIdx - 1].date) + } + + // The date string to highlight in charts (only shown if it falls within the current trend window) + const selDateForCharts = selectedDay?.date return (

Health

- {/* Daily snapshot */} - + = 0 && selectedIdx < allDaysSorted.length - 1} + hasNewer={selectedIdx > 0} + /> - {/* Divider */}
- {/* Trends section */}
-
-

Trends

+
+
+

Trends

+

Click any point to load that day above

+
{RANGES.map(({ label, days }) => (