Initial Commit

This commit is contained in:
2026-06-06 13:23:33 +01:00
commit 1a0d45dd67
58 changed files with 5268 additions and 0 deletions
+272
View File
@@ -0,0 +1,272 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
LineChart, Line, 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 = [
{ label: '2W', days: 14 },
{ label: '1M', days: 30 },
{ label: '3M', days: 90 },
{ label: '6M', days: 180 },
{ label: '1Y', days: 365 },
]
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}>
<defs>
<linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
width={32}
tickFormatter={formatter}
/>
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]}
/>
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2}
fill={`url(#grad-${dataKey})`}
dot={false}
connectNulls={false}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
)
}
function SleepChart({ data }) {
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,
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,
}))
return (
<ResponsiveContainer width="100%" height={140}>
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
tickFormatter={v => `${v}h`} />
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" radius={[0, 0, 0, 0]} />
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)
}
export default function HealthPage() {
const [rangeDays, setRangeDays] = useState(30)
const fromDate = subDays(new Date(), rangeDays).toISOString()
const { data: summary } = useQuery({
queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
const { data: metrics } = useQuery({
queryKey: ['health-metrics', rangeDays],
queryFn: () =>
api.get('/health-metrics/', {
params: { from_date: fromDate, limit: rangeDays },
}).then(r => r.data.reverse()),
})
const latest = summary?.latest
const avg30 = summary?.avg_30d
return (
<div className="p-6 space-y-6">
<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="Avg Stress"
value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'}
/>
<StatCard
label="SpO2"
value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'}
/>
</div>
{/* Range selector */}
<div className="flex gap-2">
{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'
}`}
>
{label}
</button>
))}
</div>
{metrics && metrics.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Resting HR */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
<MetricChart
data={metrics}
dataKey="resting_hr"
color="#f43f5e"
formatter={v => `${Math.round(v)} bpm`}
/>
</div>
{/* HRV */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
<MetricChart
data={metrics}
dataKey="hrv_nightly_avg"
color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`}
/>
</div>
{/* Sleep */}
<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>
<SleepChart data={metrics} />
<div className="flex gap-4 mt-2">
{[
{ label: 'Deep', color: '#6366f1' },
{ label: 'REM', color: '#8b5cf6' },
{ label: 'Light', color: '#a78bfa' },
{ label: 'Awake', color: '#374151' },
].map(({ label, color }) => (
<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>
</div>
))}
</div>
</div>
{/* Weight */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
<MetricChart
data={metrics}
dataKey="weight_kg"
color="#34d399"
formatter={v => `${v.toFixed(1)} kg`}
/>
</div>
{/* VO2 Max */}
<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>
{/* Steps */}
<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}>
<BarChart data={metrics} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</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 yet</p>
<p className="text-sm mt-1">Import a Garmin export to see your health trends</p>
</div>
)}
</div>
)
}