Initial Commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user