import { useState, useMemo } from 'react'
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import {
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 } from '../utils/format'
const RANGES = [
{ label: '1W', days: 7 },
{ label: '2W', days: 14 },
{ label: '1M', days: 30 },
{ label: '3M', days: 90 },
{ label: '6M', days: 180 },
{ label: '1Y', days: 365 },
]
const tooltipStyle = {
background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12,
}
// 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 '--'
return new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
}
function IntradayHrChart({ values }) {
if (!values?.length) return null
const data = values.map(([ts, hr]) => ({ t: ts, hr }))
return (
format(new Date(ts), 'HH:mm')}
interval={Math.max(1, Math.floor(data.length / 6))} />
Math.round(v)} domain={['auto', 'auto']} />
format(new Date(ts), 'HH:mm')}
formatter={v => [`${Math.round(v)} bpm`, 'HR']} />
)
}
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 NavArrow({ onClick, disabled, children }) {
return (
)
}
function DailySnapshot({ day, avg30, intradayHr, onOlder, onNewer, hasOlder, hasNewer }) {
if (!day) return (
📊
No health data yet
Import a Garmin export to see your daily snapshot
)
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 = day.steps ? Math.min(100, Math.round(day.steps / stepsGoal * 100)) : 0
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 (
{/* Header + arrows */}
←
Daily snapshot
{dateLabel}
→
{/* Sleep (wide) + Heart / HRV */}
Sleep
{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', 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 ? (
{label} {formatSleep(secs)}
) : null)}
>
) : !day.sleep_duration_s ? (
No sleep data
) : null}
Heart & HRV
Resting HR
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
bpm
{avg30?.resting_hr && day.resting_hr && (
30d avg {Math.round(avg30.resting_hr)} bpm
{day.resting_hr < avg30.resting_hr
? ↓
: day.resting_hr > avg30.resting_hr
? ↑
: null}
)}
HRV
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
ms
{day.avg_hr_day && (
Avg HR (day)
{Math.round(day.avg_hr_day)}
{day.max_hr_day && / {Math.round(day.max_hr_day)} max bpm}
)}
{day.weight_kg && (
Weight
{day.weight_kg.toFixed(1)}
kg
{day.body_fat_pct && {day.body_fat_pct.toFixed(1)}% fat}
)}
{/* 24-hour heart rate chart */}
{intradayHr?.length > 0 && (
24-hour Heart Rate
{day.avg_hr_day && (
avg {Math.round(day.avg_hr_day)} bpm
)}
)}
{/* Activity strip */}
Steps
{day.steps ? day.steps.toLocaleString() : '--'}
{day.steps ? (
<>
{stepsPct}% of {stepsGoal.toLocaleString()}
>
) : null}
{day.floors_climbed
?
{day.floors_climbed} floors
: null}
Calories
{day.total_calories
? Math.round(day.total_calories)
: day.active_calories ? Math.round(day.active_calories) : '--'}
kcal
{day.active_calories && day.total_calories && (
Active {Math.round(day.active_calories)} kcal
)}
Stress
{day.avg_stress ? Math.round(day.avg_stress) : '--'}
{day.avg_stress && /100}
{stressLabel &&
{stressLabel}
}
{day.spo2_avg ? (
<>
SpO2
{day.spo2_avg.toFixed(1)}
%
>
) : day.vo2max ? (
<>
VO2 Max
{day.vo2max.toFixed(1)}
{day.fitness_age &&
Fitness age {day.fitness_age}
}
>
) : (
<>
SpO2
--
>
)}
)
}
// ── Trend Charts ────────────────────────────────────────────────────────────
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)
}}
>
format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
{selectedDate && (
)}
)
}
function SleepChart({ data, selectedDate, onDayClick }) {
const chartData = data.map(d => ({
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
)
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 && (
)}
)
}
// ── 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 { data: summary } = useQuery({
queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
// 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: { limit: 365 } })
.then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
})
// 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 { data: intradayData } = useQuery({
queryKey: ['health-intraday', selectedDay?.date],
queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data),
enabled: !!selectedDay?.date,
})
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
= 0 && selectedIdx < allDaysSorted.length - 1}
hasNewer={selectedIdx > 0}
/>
Trends
Click any point to load that day above
{RANGES.map(({ label, days }) => (
))}
{isLoading ? (
Loading…
) : metrics.length > 0 ? (
Resting Heart Rate
`${Math.round(v)} bpm`}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
HRV (nightly avg)
`${Math.round(v)} ms`}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
Sleep
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
))}
Weight
`${v.toFixed(1)} kg`}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
Daily Steps
{
const p = evt?.activePayload?.[0]?.payload
if (p?.date) handleDayClick(p.date)
}}
>
format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
format(new Date(d), 'MMM d, yyyy')} />
{selDateForCharts && (
)}
Stress Level
Math.round(v)}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
Avg Heart Rate (day)
`${Math.round(v)} bpm`}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
{metrics.some(d => d.vo2max) && (
VO2 Max
v.toFixed(1)}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
)}
) : (
No trend data for this period
Try a longer date range
)}
)
}