diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx
index 90dd7df..4171c9e 100644
--- a/frontend/src/pages/HealthPage.jsx
+++ b/frontend/src/pages/HealthPage.jsx
@@ -1,12 +1,11 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
- LineChart, Line, AreaChart, Area, BarChart, Bar,
+ AreaChart, Area, BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'
import { format, subDays } from 'date-fns'
import api from '../utils/api'
-import StatCard from '../components/ui/StatCard'
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
const RANGES = [
@@ -20,6 +19,281 @@ const RANGES = [
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
+// ── Daily Snapshot helpers ──────────────────────────────────────────────────
+
+function fmtTime(ts) {
+ if (!ts) return '--'
+ return new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
+}
+
+function SleepStagesBar({ deep, light, rem, awake }) {
+ const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
+ if (!total) return null
+ const pct = s => `${((s || 0) / total * 100).toFixed(1)}%`
+ return (
+
+ )
+}
+
+function HrvBadge({ status }) {
+ if (!status) return null
+ const palette = {
+ 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',
+ }
+ 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'
+ return (
+
+
{label}
+
+ {value}
+ {unit && {unit}}
+
+ {sub &&
{sub}
}
+
+ )
+}
+
+function DailySnapshot({ latest, avg30 }) {
+ if (!latest) return (
+
+
📊
+
No health data yet
+
Import a Garmin export to see your daily snapshot
+
+ )
+
+ 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 stepsGoal = 10000
+ const stepsPct = latest.steps ? Math.min(100, (latest.steps / stepsGoal * 100).toFixed(0)) : 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'
+
+ return (
+
+
+
Daily snapshot
+
{dateLabel}
+
+
+ {/* Top row: 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)}
+
+ )}
+
+
+ {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'],
+ ].map(([label, secs, color]) => secs ? (
+
+
+
+ {label} {formatSleep(secs)}
+
+
+ ) : null)}
+
+ >
+ ) : !latest.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) : '--'}
+
+ bpm
+
+ {avg30?.resting_hr && latest.resting_hr && (
+
+ 30d avg {Math.round(avg30.resting_hr)} bpm
+ {latest.resting_hr < avg30.resting_hr
+ ? ↓
+ : latest.resting_hr > avg30.resting_hr
+ ? ↑
+ : null}
+
+ )}
+
+
+
+
HRV
+
+
+ {latest.hrv_nightly_avg ? Math.round(latest.hrv_nightly_avg) : '--'}
+
+ ms
+
+
+
+
+ {latest.avg_hr_day && (
+
+
Avg HR (day)
+
+
+ {Math.round(latest.avg_hr_day)}
+
+ bpm
+
+
+ )}
+
+
+
+ {/* Activity strip */}
+
+
+ {/* Steps */}
+
+
Steps
+
+
+ {latest.steps ? latest.steps.toLocaleString() : '--'}
+
+
+ {latest.steps ? (
+ <>
+
+
{stepsPct}% of {stepsGoal.toLocaleString()}
+ >
+ ) : null}
+ {latest.floors_climbed ? (
+
{latest.floors_climbed} floors
+ ) : null}
+
+
+ {/* Calories */}
+
+
Calories
+
+
+ {latest.total_calories
+ ? Math.round(latest.total_calories)
+ : latest.active_calories
+ ? Math.round(latest.active_calories)
+ : '--'}
+
+ kcal
+
+ {latest.active_calories && latest.total_calories && (
+
+ Active {Math.round(latest.active_calories)} kcal
+
+ )}
+
+
+ {/* Stress */}
+
+
Stress
+
+
+ {latest.avg_stress ? Math.round(latest.avg_stress) : '--'}
+
+ {latest.avg_stress && /100}
+
+ {stressLabel &&
{stressLabel}
}
+
+
+ {/* SpO2 or VO2 Max */}
+
+ {latest.spo2_avg ? (
+ <>
+
SpO2
+
+ {latest.spo2_avg.toFixed(1)}
+ %
+
+ >
+ ) : latest.vo2max ? (
+ <>
+
VO2 Max
+
+ {latest.vo2max.toFixed(1)}
+
+ {latest.fitness_age && (
+
Fitness age {latest.fitness_age}
+ )}
+ >
+ ) : (
+ <>
+
SpO2
+
--
+ >
+ )}
+
+
+
+ )
+}
+
+// ── Trend Charts ────────────────────────────────────────────────────────────
+
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return (
@@ -76,8 +350,10 @@ function SleepChart({ data }) {
)
}
+// ── Page ────────────────────────────────────────────────────────────────────
+
export default function HealthPage() {
- const [rangeDays, setRangeDays] = useState(7) // default 1 week
+ const [rangeDays, setRangeDays] = useState(7)
const fromDate = useMemo(() => format(subDays(new Date(), rangeDays), 'yyyy-MM-dd'), [rangeDays])
@@ -91,7 +367,7 @@ export default function HealthPage() {
queryFn: () =>
api.get('/health-metrics/', {
params: { from_date: fromDate, limit: rangeDays + 1 },
- }).then(r => r.data.slice().reverse()), // oldest first for charts
+ }).then(r => r.data.slice().reverse()),
keepPreviousData: true,
})
@@ -99,115 +375,111 @@ export default function HealthPage() {
const avg30 = summary?.avg_30d
return (
-
+
Health
- {/* Summary cards */}
-
-
-
-
-
-
-
-
-
-
+ {/* Daily snapshot */}
+
- {/* Range selector */}
-
- {RANGES.map(({ label, days }) => (
-
- ))}
-
+ {/* Divider */}
+
- {isLoading ? (
-
Loading…
- ) : metrics && metrics.length > 0 ? (
-
-
-
-
Resting Heart Rate
-
`${Math.round(v)} bpm`} />
+ {/* Trends section */}
+
+
+
Trends
+
+ {RANGES.map(({ label, days }) => (
+
+ ))}
+
-
-
HRV (nightly avg)
- `${Math.round(v)} ms`} />
-
+ {isLoading ? (
+
Loading…
+ ) : metrics && metrics.length > 0 ? (
+
-
-
Sleep Stages
-
-
- {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
-
- ))}
+
+
Resting Heart Rate
+ `${Math.round(v)} bpm`} />
-
-
-
Weight
- `${v.toFixed(1)} kg`} />
-
+
+
HRV (nightly avg)
+ `${Math.round(v)} ms`} />
+
-
-
VO2 Max
- v.toFixed(1)} />
-
+
+
Sleep
+
+
+ {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
+
+ ))}
+
+
-
-
Daily Steps
-
-
-
- format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
- v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
- format(new Date(d), 'MMM d, yyyy')} />
-
-
-
-
+
+
Weight
+ `${v.toFixed(1)} kg`} />
+
-
-
Avg Heart Rate (day)
- `${Math.round(v)} bpm`} />
-
+
+
Daily Steps
+
+
+
+ format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
+ v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
+ format(new Date(d), 'MMM d, yyyy')} />
+
+
+
+
-
-
Stress Level
- Math.round(v)} />
-
+
+
Stress Level
+ Math.round(v)} />
+
-
- ) : (
-
-
📊
-
No health data for this period
-
Import a Garmin export or try a longer date range
-
- )}
+
+
Avg Heart Rate (day)
+ `${Math.round(v)} bpm`} />
+
+
+ {metrics.some(d => d.vo2max) && (
+
+
VO2 Max
+ v.toFixed(1)} />
+
+ )}
+
+
+ ) : (
+
+
No trend data for this period
+
Try a longer date range
+
+ )}
+
)
}