Health hypnogram, routes tiles, BB bar chart, segment delta
- Sleep: store per-epoch stage timestamps in new sleep_stages JSON column; DailySnapshot now renders a proper 4-lane hypnogram (Awake/REM/Light/Deep) instead of the old proportional flat bar - Body battery: replace grey background bars + white line with per-minute bars coloured by inferred type (sleep=indigo, rest=teal, active=orange, stable=grey) derived from sleep window + battery direction; Y-axis fixed 0-100 - Routes: convert sidebar list to tile grid sorted by most completions; tiles colour-bordered by sport type (blue=running, orange=cycling); completion count shown on each tile; detail panel displays below the grid when a tile is clicked - Segments on activity detail: add column headers (This run / Best / Δ) and show signed time delta vs best, green when faster, red when slower Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+118
-108
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
||||
import {
|
||||
AreaChart, Area, BarChart, Bar, ComposedChart, Line, ReferenceLine,
|
||||
AreaChart, Area, BarChart, Bar, ReferenceLine,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
|
||||
} from 'recharts'
|
||||
import { format, subDays } from 'date-fns'
|
||||
@@ -60,8 +60,18 @@ function IntradayHrChart({ values }) {
|
||||
|
||||
// ── Body Battery ─────────────────────────────────────────────────────────────
|
||||
|
||||
const BB_TYPE_COLOR = { 0: '#3b82f6', 1: '#6b7280', 2: '#1e3a5f', 3: '#f97316', 4: '#374151' }
|
||||
const BB_TYPE_LABEL = { 0: 'Rest', 1: 'Active', 2: 'Sleep', 3: 'Stress', 4: 'Unmeasurable' }
|
||||
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'
|
||||
@@ -71,31 +81,36 @@ function bbLevelColor(level) {
|
||||
return '#ef4444'
|
||||
}
|
||||
|
||||
function BodyBatteryChart({ bb, hiresValues }) {
|
||||
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 BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
|
||||
if (!bb) return null
|
||||
const { charged, drained, start_level, end_level, values } = bb
|
||||
if (!values?.length && end_level == null) return null
|
||||
const { charged, drained, start_level, end_level } = bb
|
||||
if (!hiresValues?.length && !bb.values?.length && end_level == null) return null
|
||||
|
||||
// Background bars use the raw checkpoint type codes to colour activity segments.
|
||||
const bgData = (values || []).map(([ts, , type]) => ({ t: ts, type: type ?? 4, bar: 100 }))
|
||||
|
||||
// Line uses hi-res data when available, otherwise the raw checkpoints.
|
||||
const lineData = hiresValues?.length
|
||||
const rawData = hiresValues?.length
|
||||
? hiresValues.map(([ts, level]) => ({ t: ts, level }))
|
||||
: (values || []).map(([ts, level]) => ({ t: ts, level }))
|
||||
: (bb.values || []).map(([ts, level]) => ({ t: ts, level }))
|
||||
|
||||
// Merge into a single dataset keyed by timestamp so both series share the same XAxis.
|
||||
const tsSet = new Set([...bgData.map(d => d.t), ...lineData.map(d => d.t)])
|
||||
const bgMap = Object.fromEntries(bgData.map(d => [d.t, d]))
|
||||
const lineMap = Object.fromEntries(lineData.map(d => [d.t, d]))
|
||||
const chartData = [...tsSet].sort((a, b) => a - b).map(t => ({
|
||||
t,
|
||||
bar: bgMap[t]?.bar ?? null,
|
||||
type: bgMap[t]?.type ?? null,
|
||||
level: lineMap[t]?.level ?? null,
|
||||
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(bgData.map(d => d.type))]
|
||||
const presentTypes = [...new Set(chartData.map(d => d.type))]
|
||||
const levelColor = bbLevelColor(end_level)
|
||||
|
||||
return (
|
||||
@@ -117,109 +132,104 @@ function BodyBatteryChart({ bb, hiresValues }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{chartData.length > 0 && (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<ResponsiveContainer width="100%" height={100}>
|
||||
<ComposedChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 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]} hide />
|
||||
<Tooltip contentStyle={tooltipStyle}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : null} />
|
||||
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={6}>
|
||||
{chartData.map((d, i) => (
|
||||
<Cell key={i} fill={d.type != null ? (BB_TYPE_COLOR[d.type] ?? '#374151') : 'transparent'} fillOpacity={0.8} />
|
||||
))}
|
||||
</Bar>
|
||||
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={1.5}
|
||||
dot={false} isAnimationActive={false} connectNulls />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex-1">
|
||||
<ResponsiveContainer width="100%" height={100}>
|
||||
<BarChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 0 }} 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]} hide />
|
||||
<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>
|
||||
</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 className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{presentTypes.map(code => (
|
||||
<div key={code} className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_TYPE_COLOR[code] }} />
|
||||
<span className="text-xs text-gray-500">{BB_TYPE_LABEL[code]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Sleep timeline bar spanning from sleep_start to sleep_end with proportional stage coloring
|
||||
function SleepTimeline({ sleepStart, sleepEnd, deep, light, rem, awake }) {
|
||||
if (!sleepStart || !sleepEnd) return null
|
||||
const stageSecs = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
|
||||
if (!stageSecs) return null
|
||||
// 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 endMs = new Date(sleepEnd).getTime()
|
||||
const windowMs = endMs - startMs
|
||||
if (windowMs <= 0) return null
|
||||
|
||||
// Build stage segments proportional to duration, but rendered across the sleep window
|
||||
const stages = [
|
||||
{ key: 'deep', secs: deep || 0, color: '#6366f1', label: 'Deep' },
|
||||
{ key: 'rem', secs: rem || 0, color: '#8b5cf6', label: 'REM' },
|
||||
{ key: 'light', secs: light || 0, color: '#a78bfa', label: 'Light' },
|
||||
{ key: 'awake', secs: awake || 0, color: '#eab308', label: 'Awake' },
|
||||
].filter(s => s.secs > 0)
|
||||
// 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 })
|
||||
})
|
||||
|
||||
// Generate hour tick marks within the sleep window
|
||||
const startHour = new Date(startMs)
|
||||
startHour.setMinutes(0, 0, 0)
|
||||
startHour.setHours(startHour.getHours() + 1)
|
||||
// Hour ticks
|
||||
const sh = new Date(startMs); sh.setMinutes(0, 0, 0); sh.setHours(sh.getHours() + 1)
|
||||
const ticks = []
|
||||
let tick = startHour.getTime()
|
||||
while (tick < endMs) {
|
||||
const pct = Math.min(100, Math.max(0, (tick - startMs) / windowMs * 100))
|
||||
ticks.push({ pct, label: new Date(tick).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) })
|
||||
tick += 3600000
|
||||
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>
|
||||
{/* Stage bars rising from the time axis */}
|
||||
<div className="relative flex overflow-hidden rounded-t-sm" style={{ height: 48 }}>
|
||||
{stages.map(s => (
|
||||
<div
|
||||
key={s.key}
|
||||
title={`${s.label}: ${Math.round(s.secs / 60)} min`}
|
||||
style={{ width: `${(s.secs / stageSecs * 100).toFixed(2)}%`, backgroundColor: s.color }}
|
||||
/>
|
||||
))}
|
||||
{/* Tick lines overlaid on bars */}
|
||||
{ticks.map((t, i) => (
|
||||
<div key={i} className="absolute top-0 bottom-0 w-px bg-black/25 pointer-events-none"
|
||||
style={{ left: `${t.pct}%` }} />
|
||||
<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>
|
||||
{/* Axis line */}
|
||||
<div className="border-t border-gray-600 relative">
|
||||
{ticks.map((t, i) => (
|
||||
<div key={i} className="absolute top-0 w-px h-1.5 bg-gray-600"
|
||||
style={{ left: `${t.pct}%` }} />
|
||||
))}
|
||||
</div>
|
||||
{/* Time labels */}
|
||||
<div className="relative h-4 mt-1">
|
||||
<span className="absolute left-0 text-xs text-gray-500">
|
||||
<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-xs text-gray-600"
|
||||
style={{ left: `${t.pct}%`, transform: 'translateX(-50%)' }}>
|
||||
<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-xs text-gray-500">
|
||||
<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>
|
||||
@@ -253,7 +263,7 @@ function NavArrow({ onClick, disabled, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||
if (!day) return (
|
||||
<div className="text-center py-10 text-gray-600">
|
||||
<p className="text-3xl mb-2">📊</p>
|
||||
@@ -313,12 +323,11 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
|
||||
</div>
|
||||
{hasSleepStages ? (
|
||||
<>
|
||||
<SleepTimeline
|
||||
<SleepHypnogram
|
||||
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
|
||||
deep={day.sleep_deep_s} light={day.sleep_light_s}
|
||||
rem={day.sleep_rem_s} awake={day.sleep_awake_s}
|
||||
stages={sleepStages}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-1">
|
||||
<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'],
|
||||
@@ -408,7 +417,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} />
|
||||
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -657,6 +666,7 @@ export default function HealthPage() {
|
||||
intradayHr={intradayData?.hr_values}
|
||||
bodyBattery={intradayData?.body_battery}
|
||||
bbHires={intradayData?.body_battery_hires}
|
||||
sleepStages={intradayData?.sleep_stages}
|
||||
onOlder={goOlder}
|
||||
onNewer={goNewer}
|
||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||
|
||||
Reference in New Issue
Block a user