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 }) => (