2ea691085f
Previously the tip was sitting just outside the outer edge of the track, making it hard to see exactly where it pointed. Now tipR=r (centre of the coloured band) so the tip is precisely at the value's position, with a narrow 5° spread for better precision. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1008 lines
43 KiB
React
1008 lines
43 KiB
React
import { useState, useMemo } from 'react'
|
|
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
|
import {
|
|
AreaChart, Area, BarChart, Bar, ReferenceLine,
|
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
|
|
} from 'recharts'
|
|
import { format, subDays } from 'date-fns'
|
|
import api from '../utils/api'
|
|
import { formatSleep, sportIcon } 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 },
|
|
{ label: '3Y', days: 1095 },
|
|
{ label: '5Y', days: 1825 },
|
|
]
|
|
|
|
// ── VO2 Max gauge ────────────────────────────────────────────────────────────
|
|
|
|
// Garmin/Cooper Institute VO2 max thresholds
|
|
// [maxAge, [fair_min, good_min, excellent_min, superior_min]]
|
|
// value < fair_min → Poor; >= superior_min → Superior
|
|
const VO2_MALE = [
|
|
[29, [41.7, 45.4, 51.1, 55.4]],
|
|
[39, [40.5, 44.0, 48.3, 54.0]],
|
|
[49, [38.5, 42.4, 46.4, 52.5]],
|
|
[59, [35.6, 39.2, 43.4, 48.9]],
|
|
[69, [32.3, 35.5, 39.5, 45.7]],
|
|
[Infinity, [29.4, 32.3, 36.7, 42.1]],
|
|
]
|
|
const VO2_FEMALE = [
|
|
[29, [36.1, 39.5, 43.9, 49.6]],
|
|
[39, [34.4, 37.8, 42.4, 47.4]],
|
|
[49, [33.0, 36.3, 39.7, 45.3]],
|
|
[59, [30.1, 33.0, 36.7, 41.1]],
|
|
[69, [27.5, 30.0, 33.0, 37.8]],
|
|
[Infinity, [25.9, 28.1, 30.9, 36.7]],
|
|
]
|
|
const VO2_CATEGORIES = [
|
|
{ label: 'Poor', color: '#ef4444' },
|
|
{ label: 'Fair', color: '#f97316' },
|
|
{ label: 'Good', color: '#22c55e' },
|
|
{ label: 'Excellent', color: '#3b82f6' },
|
|
{ label: 'Superior', color: '#a855f7' },
|
|
]
|
|
|
|
function getVo2Category(value, age, sex) {
|
|
const table = sex === 'female' ? VO2_FEMALE : VO2_MALE
|
|
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
|
const thresholds = row[1]
|
|
// thresholds are lower-bounds: count how many the value meets or exceeds
|
|
const idx = thresholds.reduce((n, t) => value >= t ? n + 1 : n, 0)
|
|
return VO2_CATEGORIES[idx]
|
|
}
|
|
|
|
function Vo2MaxGauge({ value, birthYear, biologicalSex }) {
|
|
const MIN = 30, MAX = 70
|
|
// cx/cy = centre of the semicircle; arc goes left→top→right (sweep=1, clockwise in SVG)
|
|
const cx = 70, cy = 74, r = 50, sw = 11
|
|
|
|
const age = birthYear ? new Date().getFullYear() - birthYear : 40
|
|
|
|
// Standard-math angle: PI = left (VO2 30), 0 = right (VO2 70)
|
|
const toAngle = v => Math.PI * (1 - Math.max(0, Math.min(1, (v - MIN) / (MAX - MIN))))
|
|
|
|
// SVG coordinates for a VO2 value at a given radius from centre
|
|
const toXY = (v, radius = r) => {
|
|
const a = toAngle(v)
|
|
return [cx + radius * Math.cos(a), cy - radius * Math.sin(a)]
|
|
}
|
|
|
|
// Arc path from VO2 v1 to v2; sweep=1 → clockwise = upper semicircle in SVG
|
|
const arc = (v1, v2, radius = r) => {
|
|
const [x1, y1] = toXY(v1, radius)
|
|
const [x2, y2] = toXY(v2, radius)
|
|
const large = 0 // gauge spans 180°, so no segment ever exceeds 180°
|
|
return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${radius} ${radius} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`
|
|
}
|
|
|
|
// ACSM category boundaries for this user's age/sex
|
|
const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE
|
|
const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1]
|
|
const thresholds = row[1]
|
|
const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands
|
|
|
|
const cat = value != null ? getVo2Category(value, age, biologicalSex) : null
|
|
|
|
// White arrow: tip lands exactly at the arc centre-line at the value's angle;
|
|
// base extends outside the track — unambiguously marks the precise position.
|
|
const arrowPts = value != null ? (() => {
|
|
const a = toAngle(Math.max(MIN, Math.min(MAX, value)))
|
|
const tipR = r // tip at centre of the coloured track
|
|
const baseR = r + sw / 2 + 9 // base well outside the outer edge
|
|
const s = 0.09 // half-spread ≈ 5° — narrow for precision
|
|
const tipX = cx + tipR * Math.cos(a), tipY = cy - tipR * Math.sin(a)
|
|
const b1x = cx + baseR * Math.cos(a + s), b1y = cy - baseR * Math.sin(a + s)
|
|
const b2x = cx + baseR * Math.cos(a - s), b2y = cy - baseR * Math.sin(a - s)
|
|
return `${tipX.toFixed(1)},${tipY.toFixed(1)} ${b1x.toFixed(1)},${b1y.toFixed(1)} ${b2x.toFixed(1)},${b2y.toFixed(1)}`
|
|
})() : null
|
|
|
|
return (
|
|
<div className="flex flex-col items-center">
|
|
<svg width="140" height="92" viewBox="0 0 140 92">
|
|
{/* Dark background track, slightly wider than the colour bands */}
|
|
<path d={arc(MIN, MAX)} stroke="#1f2937" strokeWidth={sw + 4} fill="none" strokeLinecap="butt" />
|
|
|
|
{/* Full-brightness ACSM colour bands */}
|
|
{VO2_CATEGORIES.map((c, i) => {
|
|
const v1 = Math.max(bounds[i], MIN)
|
|
const v2 = Math.min(bounds[i + 1], MAX)
|
|
if (v2 <= v1) return null
|
|
return (
|
|
<path key={i} d={arc(v1, v2)}
|
|
stroke={c.color} strokeWidth={sw} fill="none" strokeLinecap="butt" />
|
|
)
|
|
})}
|
|
|
|
{/* White arrow: tip at exact value position on arc, base pointing outward */}
|
|
{arrowPts && <polygon points={arrowPts} fill="white" />}
|
|
|
|
{/* VO2 number, coloured by category */}
|
|
<text x={cx} y={cy - 6} textAnchor="middle" dominantBaseline="middle"
|
|
fontSize="21" fontWeight="700" fill={cat?.color ?? '#6b7280'}>
|
|
{value != null ? value.toFixed(1) : '--'}
|
|
</text>
|
|
|
|
{/* Category label */}
|
|
<text x={cx} y={cy + 11} textAnchor="middle" dominantBaseline="middle"
|
|
fontSize="9" fill="#9ca3af">
|
|
{cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')}
|
|
</text>
|
|
</svg>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<ResponsiveContainer width="100%" height={100}>
|
|
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
|
<defs>
|
|
<linearGradient id="grad-intraday-hr" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
|
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
|
interval={Math.max(1, Math.floor(data.length / 6))} />
|
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
|
tickFormatter={v => Math.round(v)} domain={['auto', 'auto']} />
|
|
<Tooltip contentStyle={tooltipStyle}
|
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
|
formatter={v => [`${Math.round(v)} bpm`, 'HR']} />
|
|
<Area type="monotone" dataKey="hr" stroke="#f43f5e" strokeWidth={1.5}
|
|
fill="url(#grad-intraday-hr)" dot={false} isAnimationActive={false} connectNulls={false} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
}
|
|
|
|
// ── Body Battery ─────────────────────────────────────────────────────────────
|
|
|
|
const BB_INFERRED_COLOR = {
|
|
sleep: '#4f46e5',
|
|
rest: '#0d9488',
|
|
activity: '#f97316',
|
|
stable: '#374151',
|
|
}
|
|
const BB_INFERRED_LABEL = {
|
|
sleep: 'Sleep',
|
|
rest: 'Rest',
|
|
activity: 'Active/Stress',
|
|
stable: 'Stable',
|
|
}
|
|
|
|
function bbLevelColor(level) {
|
|
if (level == null) return '#6b7280'
|
|
if (level >= 75) return '#3b82f6'
|
|
if (level >= 50) return '#22c55e'
|
|
if (level >= 25) return '#f59e0b'
|
|
return '#ef4444'
|
|
}
|
|
|
|
function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) {
|
|
const inSleep = sleepStartMs != null && sleepEndMs != null && tsMs >= sleepStartMs && tsMs <= sleepEndMs
|
|
if (inSleep) return 'sleep'
|
|
if (prevLevel != null) {
|
|
if (level > prevLevel + 0.3) return 'rest'
|
|
if (level < prevLevel - 0.3) return 'activity'
|
|
}
|
|
return 'stable'
|
|
}
|
|
|
|
function ActivityRefLabel({ viewBox, icon }) {
|
|
if (!viewBox) return null
|
|
const { x, y } = viewBox
|
|
return (
|
|
<text x={x} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
|
|
{icon}
|
|
</text>
|
|
)
|
|
}
|
|
|
|
function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) {
|
|
if (!bb) return null
|
|
const { charged, drained, start_level, end_level } = bb
|
|
if (!hiresValues?.length && !bb.values?.length && end_level == null) return null
|
|
|
|
const rawData = hiresValues?.length
|
|
? hiresValues.map(([ts, level]) => ({ t: ts, level }))
|
|
: (bb.values || []).map(([ts, level]) => ({ t: ts, level }))
|
|
|
|
if (!rawData.length) return null
|
|
|
|
const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
|
|
const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
|
|
|
|
const chartData = rawData.map((d, i) => ({
|
|
...d,
|
|
type: inferBBType(d.t, d.level, i > 0 ? rawData[i - 1].level : null, sleepStartMs, sleepEndMs),
|
|
}))
|
|
|
|
const presentTypes = [...new Set(chartData.map(d => d.type))]
|
|
const levelColor = bbLevelColor(end_level)
|
|
const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null
|
|
|
|
return (
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col h-full">
|
|
<h3 className="text-sm font-medium text-gray-300 mb-2">Body Battery</h3>
|
|
|
|
<div className="flex items-baseline gap-3 flex-wrap mb-3">
|
|
{maxLevel != null && (
|
|
<span className="text-3xl font-bold" style={{ color: bbLevelColor(maxLevel) }}>{Math.round(maxLevel)}</span>
|
|
)}
|
|
{charged != null && (
|
|
<span className="text-sm font-semibold text-green-400">+{charged}</span>
|
|
)}
|
|
{drained != null && (
|
|
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
|
|
)}
|
|
{end_level != null && (
|
|
<span className="text-xs text-gray-500">now {Math.round(end_level)}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<ResponsiveContainer width="100%" height={100}>
|
|
<BarChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 28 }} barCategoryGap={0}>
|
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
|
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
|
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
|
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
|
tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} />
|
|
<Tooltip contentStyle={tooltipStyle}
|
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
|
formatter={v => [`${Math.round(v)}`, 'Battery']} />
|
|
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
|
{chartData.map((d, i) => (
|
|
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
|
))}
|
|
</Bar>
|
|
{(activities || []).map(a => (
|
|
<ReferenceLine
|
|
key={a.id}
|
|
x={new Date(a.start_time).getTime()}
|
|
stroke="rgba(255,255,255,0.3)"
|
|
strokeWidth={1.5}
|
|
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
|
|
/>
|
|
))}
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
|
{presentTypes.map(type => (
|
|
<div key={type} className="flex items-center gap-1">
|
|
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
|
|
<span className="text-xs text-gray-500">{BB_INFERRED_LABEL[type]}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Proper sleep hypnogram: 4 horizontal lanes (Awake/REM/Light/Deep), time on X axis
|
|
const SLEEP_LANE_ORDER = [1, 4, 2, 3] // top→bottom: awake, rem, light, deep
|
|
const SLEEP_STAGE_COLOR = { 0: '#6b7280', 1: '#eab308', 2: '#a78bfa', 3: '#6366f1', 4: '#8b5cf6' }
|
|
const SLEEP_STAGE_LABEL = { 1: 'Awake', 2: 'Light', 3: 'Deep', 4: 'REM' }
|
|
const LANE_H = 15
|
|
|
|
function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
|
|
if (!sleepStart || !sleepEnd || !stages?.length) return null
|
|
const startMs = new Date(sleepStart).getTime()
|
|
const endMs = new Date(sleepEnd).getTime()
|
|
const windowMs = endMs - startMs
|
|
if (windowMs <= 0) return null
|
|
|
|
// Build segments per lane
|
|
const segsByLane = {}
|
|
SLEEP_LANE_ORDER.forEach(lv => { segsByLane[lv] = [] })
|
|
stages.forEach(([tsMs, level], i) => {
|
|
if (!(level in segsByLane)) return
|
|
const nextTs = i + 1 < stages.length ? stages[i + 1][0] : endMs
|
|
const left = Math.max(0, (tsMs - startMs) / windowMs * 100)
|
|
const right = Math.min(100, (nextTs - startMs) / windowMs * 100)
|
|
const w = right - left
|
|
if (w > 0) segsByLane[level].push({ left, w })
|
|
})
|
|
|
|
// Hour ticks
|
|
const sh = new Date(startMs); sh.setMinutes(0, 0, 0); sh.setHours(sh.getHours() + 1)
|
|
const ticks = []
|
|
for (let t = sh.getTime(); t < endMs; t += 3600000) {
|
|
const pct = (t - startMs) / windowMs * 100
|
|
if (pct >= 0 && pct <= 100)
|
|
ticks.push({ pct, label: new Date(t).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) })
|
|
}
|
|
|
|
return (
|
|
<div className="pl-10">
|
|
<div className="space-y-px">
|
|
{SLEEP_LANE_ORDER.map(level => (
|
|
<div key={level} className="relative flex items-center">
|
|
<span className="absolute right-full pr-1.5 text-gray-500 whitespace-nowrap select-none"
|
|
style={{ fontSize: 10 }}>
|
|
{SLEEP_STAGE_LABEL[level]}
|
|
</span>
|
|
<div className="relative flex-1 rounded-sm overflow-hidden bg-gray-800/50" style={{ height: LANE_H }}>
|
|
{segsByLane[level].map((seg, i) => (
|
|
<div key={i} className="absolute top-0 h-full"
|
|
style={{ left: `${seg.left}%`, width: `${seg.w}%`, backgroundColor: SLEEP_STAGE_COLOR[level] }} />
|
|
))}
|
|
{ticks.map((t, i) => (
|
|
<div key={i} className="absolute top-0 bottom-0 w-px bg-black/20 pointer-events-none"
|
|
style={{ left: `${t.pct}%` }} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="relative h-4 mt-1 ml-0">
|
|
<span className="absolute left-0 text-gray-500" style={{ fontSize: 10 }}>
|
|
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
{ticks.map((t, i) => (
|
|
<span key={i} className="absolute text-gray-600"
|
|
style={{ left: `${t.pct}%`, transform: 'translateX(-50%)', fontSize: 10 }}>
|
|
{t.label}
|
|
</span>
|
|
))}
|
|
<span className="absolute right-0 text-gray-500" style={{ fontSize: 10 }}>
|
|
{new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
|
|
const total = (deepS || 0) + (remS || 0) + (lightS || 0) + (awakeS || 0)
|
|
if (!total) return null
|
|
const segments = [
|
|
{ label: 'Deep', s: deepS || 0, color: '#6366f1' },
|
|
{ label: 'REM', s: remS || 0, color: '#8b5cf6' },
|
|
{ label: 'Light', s: lightS || 0, color: '#a78bfa' },
|
|
{ label: 'Awake', s: awakeS || 0, color: '#eab308' },
|
|
].filter(seg => seg.s > 0)
|
|
return (
|
|
<div className="mt-2">
|
|
<div className="flex h-5 rounded-sm overflow-hidden">
|
|
{segments.map(seg => (
|
|
<div key={seg.label} style={{ width: `${(seg.s / total) * 100}%`, backgroundColor: seg.color }} />
|
|
))}
|
|
</div>
|
|
</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 NavArrow({ onClick, disabled, children }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className="w-7 h-7 flex items-center justify-center rounded-lg text-gray-400
|
|
hover:text-white hover:bg-gray-800 disabled:opacity-20 disabled:cursor-default
|
|
transition-colors text-base font-medium"
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) {
|
|
if (!day) 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 = 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 (
|
|
<div className="space-y-4">
|
|
|
|
{/* Header + arrows */}
|
|
<div className="flex items-center gap-3">
|
|
<NavArrow onClick={onOlder} disabled={!hasOlder}>←</NavArrow>
|
|
<div className="min-w-0">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wide">Daily snapshot</p>
|
|
<h2 className="text-xl font-semibold text-white leading-tight">{dateLabel}</h2>
|
|
</div>
|
|
<NavArrow onClick={onNewer} disabled={!hasNewer}>→</NavArrow>
|
|
</div>
|
|
|
|
{/* Sleep (wide) + Heart / HRV */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
|
|
<div className="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>
|
|
{day.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(day.sleep_score)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-end gap-3">
|
|
<span className="text-4xl font-bold text-white tracking-tight">
|
|
{formatSleep(day.sleep_duration_s)}
|
|
</span>
|
|
{day.sleep_start && day.sleep_end && (
|
|
<span className="text-sm text-gray-500 pb-1">
|
|
{fmtTime(day.sleep_start)} → {fmtTime(day.sleep_end)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{hasSleepStages ? (
|
|
<>
|
|
{sleepStages?.length ? (
|
|
<SleepHypnogram
|
|
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
|
|
stages={sleepStages}
|
|
/>
|
|
) : (
|
|
<SleepStageFallbackBar
|
|
deepS={day.sleep_deep_s} remS={day.sleep_rem_s}
|
|
lightS={day.sleep_light_s} awakeS={day.sleep_awake_s}
|
|
/>
|
|
)}
|
|
<div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-2">
|
|
{[
|
|
['Deep', day.sleep_deep_s, '#6366f1'],
|
|
['REM', day.sleep_rem_s, '#8b5cf6'],
|
|
['Light', day.sleep_light_s, '#a78bfa'],
|
|
['Awake', day.sleep_awake_s, '#eab308'],
|
|
].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>
|
|
</>
|
|
) : !day.sleep_duration_s ? (
|
|
<p className="text-sm text-gray-600">No sleep data</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart & HRV</h3>
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
|
<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">
|
|
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
|
|
</span>
|
|
<span className="text-sm text-gray-500">bpm</span>
|
|
</div>
|
|
{avg30?.resting_hr && day.resting_hr && (
|
|
<p className="text-xs text-gray-500 mt-0.5">
|
|
30d avg {Math.round(avg30.resting_hr)}
|
|
{day.resting_hr < avg30.resting_hr
|
|
? <span className="text-green-400 ml-1">↓</span>
|
|
: day.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">
|
|
{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
|
|
</span>
|
|
<span className="text-sm text-gray-500">ms</span>
|
|
</div>
|
|
<div className="mt-0.5"><HrvBadge status={day.hrv_status} /></div>
|
|
</div>
|
|
<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">
|
|
{day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'}
|
|
</span>
|
|
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max</span>}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
|
|
<div className="flex items-baseline gap-1.5 flex-wrap">
|
|
<span className="text-xl font-semibold text-emerald-400">
|
|
{day.weight_kg ? day.weight_kg.toFixed(1) : '--'}
|
|
</span>
|
|
{day.weight_kg && <span className="text-xs text-gray-500">kg</span>}
|
|
{day.body_fat_pct && <span className="text-xs text-gray-500">{day.body_fat_pct.toFixed(1)}% fat</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 24-hour heart rate chart + body battery (side by side) */}
|
|
{(intradayHr?.length > 0 || bodyBattery) && (
|
|
<div className={`grid gap-4 ${intradayHr?.length > 0 && bodyBattery ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}>
|
|
{intradayHr?.length > 0 && (
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h3 className="text-sm font-medium text-gray-300">24-hour Heart Rate</h3>
|
|
{day.avg_hr_day && (
|
|
<span className="text-xs text-gray-500">avg {Math.round(day.avg_hr_day)} bpm</span>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-h-0">
|
|
<IntradayHrChart values={intradayHr} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} activities={activities} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Activity strip */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
|
|
<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">
|
|
{day.steps ? day.steps.toLocaleString() : '--'}
|
|
</span>
|
|
</div>
|
|
{day.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}
|
|
{day.floors_climbed
|
|
? <p className="text-xs text-gray-500 mt-1">{day.floors_climbed} floors</p>
|
|
: null}
|
|
</div>
|
|
|
|
<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">
|
|
{day.total_calories
|
|
? Math.round(day.total_calories)
|
|
: day.active_calories ? Math.round(day.active_calories) : '--'}
|
|
</span>
|
|
<span className="text-xs text-gray-500">kcal</span>
|
|
</div>
|
|
{day.active_calories && day.total_calories && (
|
|
<p className="text-xs text-gray-500 mt-1">Active {Math.round(day.active_calories)} kcal</p>
|
|
)}
|
|
</div>
|
|
|
|
<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}`}>
|
|
{day.avg_stress ? Math.round(day.avg_stress) : '--'}
|
|
</span>
|
|
{day.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>
|
|
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col">
|
|
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<Vo2MaxGauge
|
|
value={(day.vo2max ?? latestVo2max) ?? null}
|
|
birthYear={birthYear}
|
|
biologicalSex={biologicalSex}
|
|
/>
|
|
</div>
|
|
{day.fitness_age && <p className="text-xs text-gray-500 mt-1 text-center">Fitness age {day.fitness_age}</p>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Trend Charts ────────────────────────────────────────────────────────────
|
|
|
|
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines }) {
|
|
const vals = data.filter(d => d[dataKey] != null)
|
|
if (!vals.length) return (
|
|
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
|
)
|
|
return (
|
|
<ResponsiveContainer width="100%" height={height}>
|
|
<AreaChart
|
|
data={data}
|
|
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
|
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
|
onClick={evt => {
|
|
const p = evt?.activePayload?.[0]?.payload
|
|
if (p?.date && onDayClick) onDayClick(p.date)
|
|
}}
|
|
>
|
|
<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={36}
|
|
tickFormatter={formatter} domain={domain} />
|
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
|
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
|
{selectedDate && (
|
|
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
|
)}
|
|
{(referenceLines || []).map((rl, i) => (
|
|
<ReferenceLine key={i} {...rl} />
|
|
))}
|
|
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
|
fill={`url(#grad-${dataKey})`}
|
|
dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false}
|
|
connectNulls={connectNulls} isAnimationActive={false} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
|
|
)
|
|
return (
|
|
<ResponsiveContainer width="100%" height={140}>
|
|
<BarChart
|
|
data={chartData}
|
|
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
|
barSize={6}
|
|
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
|
onClick={evt => {
|
|
const p = evt?.activePayload?.[0]?.payload
|
|
if (p?.date && onDayClick) onDayClick(p.date)
|
|
}}
|
|
>
|
|
<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={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
|
{selectedDate && (
|
|
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
|
)}
|
|
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
|
|
<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="#eab308" radius={[2, 2, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
)
|
|
}
|
|
|
|
// ── 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),
|
|
})
|
|
|
|
const { data: profile } = useQuery({
|
|
queryKey: ['profile'],
|
|
queryFn: () => api.get('/profile/').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: 2000 } })
|
|
.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])
|
|
|
|
// Most recent day with a VO2 max reading (Garmin only updates it after certain activities)
|
|
const latestVo2max = useMemo(() => {
|
|
const found = allDaysSorted.find(d => d.vo2max != null)
|
|
return found ? found.vo2max : null
|
|
}, [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 { data: dayActivities } = useQuery({
|
|
queryKey: ['activities-day', selectedDay?.date],
|
|
queryFn: () => api.get('/activities/', { params: {
|
|
from_date: selectedDay.date + 'T00:00:00',
|
|
to_date: selectedDay.date + 'T23:59:59',
|
|
per_page: 20,
|
|
}}).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 (
|
|
<div className="p-6 space-y-8">
|
|
<h1 className="text-2xl font-bold text-white">Health</h1>
|
|
|
|
<DailySnapshot
|
|
day={selectedDay}
|
|
avg30={summary?.avg_30d}
|
|
intradayHr={intradayData?.hr_values}
|
|
bodyBattery={intradayData?.body_battery}
|
|
bbHires={intradayData?.body_battery_hires}
|
|
sleepStages={intradayData?.sleep_stages}
|
|
activities={dayActivities}
|
|
latestVo2max={latestVo2max}
|
|
birthYear={profile?.birth_year}
|
|
biologicalSex={profile?.biological_sex}
|
|
onOlder={goOlder}
|
|
onNewer={goNewer}
|
|
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
|
hasNewer={selectedIdx > 0}
|
|
/>
|
|
|
|
<div className="border-t border-gray-800" />
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
<div>
|
|
<h2 className="text-base font-semibold text-gray-300">Trends</h2>
|
|
<p className="text-xs text-gray-600">Click any point to load that day above</p>
|
|
</div>
|
|
<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'
|
|
}`}>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="text-gray-500 text-sm">Loading…</div>
|
|
) : metrics.length > 0 ? (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
|
|
<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)}
|
|
domain={[0, 200]}
|
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
|
</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">HRV (nightly avg)</h3>
|
|
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
|
formatter={v => `${Math.round(v)} ms`}
|
|
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
|
referenceLines={[
|
|
{ y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } },
|
|
{ y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
|
|
]}
|
|
/>
|
|
</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</h3>
|
|
<SleepChart data={metrics}
|
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
|
<div className="flex gap-4 mt-2">
|
|
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
|
|
<div key={l} className="flex items-center gap-1.5">
|
|
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
|
<span className="text-xs text-gray-400">{l}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</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">Weight</h3>
|
|
<MetricChart
|
|
data={metrics.filter(d => d.weight_kg != null)}
|
|
dataKey="weight_kg" color="#34d399"
|
|
formatter={v => `${v.toFixed(1)} kg`}
|
|
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
|
connectNulls showDots />
|
|
</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}>
|
|
<BarChart
|
|
data={metrics}
|
|
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
|
barSize={6}
|
|
style={{ cursor: 'pointer' }}
|
|
onClick={evt => {
|
|
const p = evt?.activePayload?.[0]?.payload
|
|
if (p?.date) handleDayClick(p.date)
|
|
}}
|
|
>
|
|
<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={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
|
|
{selDateForCharts && (
|
|
<ReferenceLine x={selDateForCharts} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
|
)}
|
|
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
|
|
</BarChart>
|
|
</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">Stress Level</h3>
|
|
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
|
|
formatter={v => Math.round(v)}
|
|
domain={[0, 100]}
|
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
|
</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">Heart Rate</h3>
|
|
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
|
formatter={v => Math.round(v)}
|
|
domain={[0, 200]}
|
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
|
</div>
|
|
|
|
{metrics.some(d => d.body_battery?.end_level != null) && (
|
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
|
<h3 className="text-sm font-medium text-gray-300 mb-3">Body Battery (end of day)</h3>
|
|
<MetricChart
|
|
data={metrics.map(d => ({ ...d, body_battery_level: d.body_battery?.end_level ?? null }))}
|
|
dataKey="body_battery_level" color="#3b82f6"
|
|
formatter={v => `${Math.round(v)}`}
|
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
|
</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)}
|
|
connectNulls showDots
|
|
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
) : (
|
|
<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>
|
|
)
|
|
}
|