Add daily health snapshot to Health page
Replaces the flat stat card grid with a rich daily view at the top: sleep card with duration, stage bar and times; heart/HRV card; activity strip (steps with progress bar, calories, stress, SpO2). Trend charts moved below under a Trends heading with the range selector inline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div className="flex rounded-full overflow-hidden h-2.5 w-full">
|
||||
<div style={{ width: pct(deep), backgroundColor: '#6366f1' }} />
|
||||
<div style={{ width: pct(rem), backgroundColor: '#8b5cf6' }} />
|
||||
<div style={{ width: pct(light), backgroundColor: '#a78bfa' }} />
|
||||
<div style={{ width: pct(awake), backgroundColor: '#374151' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">{label}</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-2xl font-bold ${accentCls}`}>{value}</span>
|
||||
{unit && <span className="text-xs text-gray-500">{unit}</span>}
|
||||
</div>
|
||||
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DailySnapshot({ latest, avg30 }) {
|
||||
if (!latest) return (
|
||||
<div className="text-center py-10 text-gray-600">
|
||||
<p className="text-3xl mb-2">📊</p>
|
||||
<p>No health data yet</p>
|
||||
<p className="text-sm mt-1">Import a Garmin export to see your daily snapshot</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">Daily snapshot</p>
|
||||
<h2 className="text-xl font-semibold text-white mt-0.5">{dateLabel}</h2>
|
||||
</div>
|
||||
|
||||
{/* Top row: Sleep (wide) + Heart/HRV */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
|
||||
{/* Sleep card — 2/3 width on desktop */}
|
||||
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{latest.sleep_score != null && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-indigo-400/30 bg-indigo-400/10 text-indigo-300">
|
||||
Score {Math.round(latest.sleep_score)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
<span className="text-4xl font-bold text-white tracking-tight">
|
||||
{formatSleep(latest.sleep_duration_s)}
|
||||
</span>
|
||||
{latest.sleep_start && latest.sleep_end && (
|
||||
<span className="text-sm text-gray-500 pb-1">
|
||||
{fmtTime(latest.sleep_start)} → {fmtTime(latest.sleep_end)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasSleepStages ? (
|
||||
<>
|
||||
<SleepStagesBar
|
||||
deep={latest.sleep_deep_s}
|
||||
light={latest.sleep_light_s}
|
||||
rem={latest.sleep_rem_s}
|
||||
awake={latest.sleep_awake_s}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1.5">
|
||||
{[
|
||||
['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 ? (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
|
||||
<span className="text-xs text-gray-400">
|
||||
{label} <span className="text-white">{formatSleep(secs)}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null)}
|
||||
</div>
|
||||
</>
|
||||
) : !latest.sleep_duration_s ? (
|
||||
<p className="text-sm text-gray-600">No sleep data</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Heart & HRV card — 1/3 width */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
||||
<h3 className="text-sm font-medium text-gray-300">Heart & HRV</h3>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-3xl font-bold text-rose-400">
|
||||
{latest.resting_hr ? Math.round(latest.resting_hr) : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">bpm</span>
|
||||
</div>
|
||||
{avg30?.resting_hr && latest.resting_hr && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
30d avg {Math.round(avg30.resting_hr)} bpm
|
||||
{latest.resting_hr < avg30.resting_hr
|
||||
? <span className="text-green-400 ml-1">↓</span>
|
||||
: latest.resting_hr > avg30.resting_hr
|
||||
? <span className="text-red-400 ml-1">↑</span>
|
||||
: null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-3xl font-bold text-violet-400">
|
||||
{latest.hrv_nightly_avg ? Math.round(latest.hrv_nightly_avg) : '--'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">ms</span>
|
||||
<HrvBadge status={latest.hrv_status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{latest.avg_hr_day && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-xl font-semibold text-orange-400">
|
||||
{Math.round(latest.avg_hr_day)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">bpm</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity strip */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
|
||||
{/* Steps */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<p className="text-xs text-gray-500 mb-1">Steps</p>
|
||||
<div className="flex items-baseline gap-1 mb-2">
|
||||
<span className="text-2xl font-bold text-yellow-400">
|
||||
{latest.steps ? latest.steps.toLocaleString() : '--'}
|
||||
</span>
|
||||
</div>
|
||||
{latest.steps ? (
|
||||
<>
|
||||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-yellow-400 rounded-full transition-all" style={{ width: `${stepsPct}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1">{stepsPct}% of {stepsGoal.toLocaleString()}</p>
|
||||
</>
|
||||
) : null}
|
||||
{latest.floors_climbed ? (
|
||||
<p className="text-xs text-gray-500 mt-1">{latest.floors_climbed} floors</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Calories */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<p className="text-xs text-gray-500 mb-1">Calories</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-white">
|
||||
{latest.total_calories
|
||||
? Math.round(latest.total_calories)
|
||||
: latest.active_calories
|
||||
? Math.round(latest.active_calories)
|
||||
: '--'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">kcal</span>
|
||||
</div>
|
||||
{latest.active_calories && latest.total_calories && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Active {Math.round(latest.active_calories)} kcal
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stress */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<p className="text-xs text-gray-500 mb-1">Stress</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-2xl font-bold ${stressColor}`}>
|
||||
{latest.avg_stress ? Math.round(latest.avg_stress) : '--'}
|
||||
</span>
|
||||
{latest.avg_stress && <span className="text-xs text-gray-500">/100</span>}
|
||||
</div>
|
||||
{stressLabel && <p className="text-xs text-gray-500 mt-1">{stressLabel}</p>}
|
||||
</div>
|
||||
|
||||
{/* SpO2 or VO2 Max */}
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
{latest.spo2_avg ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-1">SpO2</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-sky-400">{latest.spo2_avg.toFixed(1)}</span>
|
||||
<span className="text-xs text-gray-500">%</span>
|
||||
</div>
|
||||
</>
|
||||
) : latest.vo2max ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-blue-400">{latest.vo2max.toFixed(1)}</span>
|
||||
</div>
|
||||
{latest.fitness_age && (
|
||||
<p className="text-xs text-gray-500 mt-1">Fitness age {latest.fitness_age}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-1">SpO2</p>
|
||||
<span className="text-2xl font-bold text-white">--</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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,38 +375,32 @@ export default function HealthPage() {
|
||||
const avg30 = summary?.avg_30d
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-6 space-y-8">
|
||||
<h1 className="text-2xl font-bold text-white">Health</h1>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)}
|
||||
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined} accent="red" />
|
||||
<StatCard label="HRV" value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
|
||||
sub={latest?.hrv_status || undefined} />
|
||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)}
|
||||
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined} />
|
||||
<StatCard label="Weight" value={formatWeight(latest?.weight_kg)}
|
||||
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined} />
|
||||
<StatCard label="VO2 Max" value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
|
||||
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined} accent="blue" />
|
||||
<StatCard label="Steps" value={latest?.steps ? latest.steps.toLocaleString() : '--'}
|
||||
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined} />
|
||||
<StatCard label="Stress" value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'} />
|
||||
<StatCard label="SpO2" value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'} />
|
||||
</div>
|
||||
{/* Daily snapshot */}
|
||||
<DailySnapshot latest={latest} avg30={avg30} />
|
||||
|
||||
{/* Range selector */}
|
||||
<div className="flex gap-2">
|
||||
{/* Divider */}
|
||||
<div className="border-t border-gray-800" />
|
||||
|
||||
{/* Trends section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-300">Trends</h2>
|
||||
<div className="flex gap-1.5">
|
||||
{RANGES.map(({ label, days }) => (
|
||||
<button key={label} onClick={() => setRangeDays(days)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
|
||||
rangeDays === days ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-700 text-gray-400 hover:text-white'
|
||||
rangeDays === days
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-gray-700 text-gray-400 hover:text-white'
|
||||
}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-gray-500 text-sm">Loading…</div>
|
||||
@@ -150,7 +420,7 @@ export default function HealthPage() {
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Stages</h3>
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep</h3>
|
||||
<SleepChart data={metrics} />
|
||||
<div className="flex gap-4 mt-2">
|
||||
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
|
||||
@@ -168,11 +438,6 @@ export default function HealthPage() {
|
||||
formatter={v => `${v.toFixed(1)} kg`} />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
||||
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" formatter={v => v.toFixed(1)} />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
@@ -188,26 +453,33 @@ export default function HealthPage() {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
|
||||
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
||||
formatter={v => `${Math.round(v)} bpm`} />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
|
||||
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
|
||||
formatter={v => Math.round(v)} />
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
|
||||
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
||||
formatter={v => `${Math.round(v)} bpm`} />
|
||||
</div>
|
||||
|
||||
{metrics.some(d => d.vo2max) && (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
|
||||
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" formatter={v => v.toFixed(1)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-gray-600">
|
||||
<p className="text-4xl mb-3">📊</p>
|
||||
<p className="text-lg">No health data for this period</p>
|
||||
<p className="text-sm mt-1">Import a Garmin export or try a longer date range</p>
|
||||
<div className="text-center py-12 text-gray-600">
|
||||
<p className="text-lg">No trend data for this period</p>
|
||||
<p className="text-sm mt-1">Try a longer date range</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user