From 67fd4b3c96c41f65b48f3164fc2670b800772680 Mon Sep 17 00:00:00 2001 From: owain Date: Sun, 7 Jun 2026 18:44:00 +0100 Subject: [PATCH] Health hypnogram, routes tiles, BB bar chart, segment delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/app/api/health.py | 1 + backend/app/main.py | 1 + backend/app/models/user.py | 1 + backend/app/services/wellness_parser.py | 3 + backend/app/workers/tasks.py | 8 +- frontend/src/pages/ActivityDetailPage.jsx | 35 ++-- frontend/src/pages/HealthPage.jsx | 226 +++++++++++----------- frontend/src/pages/RoutesPage.jsx | 102 ++++++---- 8 files changed, 208 insertions(+), 169 deletions(-) diff --git a/backend/app/api/health.py b/backend/app/api/health.py index b72c903..6b00298 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -147,6 +147,7 @@ async def intraday_health( "hr_values": metric.intraday_hr if metric else None, "body_battery": metric.body_battery if metric else None, "body_battery_hires": metric.body_battery_hires if metric else None, + "sleep_stages": metric.sleep_stages if metric else None, } diff --git a/backend/app/main.py b/backend/app/main.py index e2dd65f..2861843 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -58,6 +58,7 @@ async def init_db(): "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS max_hr_day FLOAT", "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB", "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB", + "ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON", ]: await conn.execute(text(stmt)) except Exception as e: diff --git a/backend/app/models/user.py b/backend/app/models/user.py index b751a7f..f2b0a61 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -248,6 +248,7 @@ class HealthMetric(Base): intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] β€” not in API list response body_battery = Column(JSON, nullable=True) # {charged,drained,start_level,end_level,values:[[ts_ms,level,type,stress]...]} body_battery_hires = Column(JSON, nullable=True) # [[ts_ms, level], ...] interpolated from bb + HR; higher resolution than raw values + sleep_stages = Column(JSON, nullable=True) # [[ts_ms, level], ...] 0=unmeasurable,1=awake,2=light,3=deep,4=rem __table_args__ = ( UniqueConstraint("user_id", "date", name="uq_health_user_date"), diff --git a/backend/app/services/wellness_parser.py b/backend/app/services/wellness_parser.py index b2c646b..9e8c5fe 100644 --- a/backend/app/services/wellness_parser.py +++ b/backend/app/services/wellness_parser.py @@ -319,8 +319,10 @@ def parse_wellness_fit(file_path: str) -> dict: sleep_rem_s = level_secs[4] or None sleep_awake_s = level_secs[1] or None sleep_duration_s = (level_secs[2] + level_secs[3] + level_secs[4]) or None + sleep_stages = [[int(ts.timestamp() * 1000), level] for ts, level in epochs_sorted] else: sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None + sleep_stages = None active_cal = data.get("active_calories") bmr = data.get("bmr") @@ -348,6 +350,7 @@ def parse_wellness_fit(file_path: str) -> dict: "sleep_score": data.get("sleep_score"), "sleep_start": sleep_start_ts, "sleep_end": sleep_end_ts, + "sleep_stages": sleep_stages, } return {"days": result, "error": None} diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 6c50fed..caf32b5 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -229,12 +229,12 @@ def parse_wellness_fit(file_path: str, user_id: int): avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status, steps, floors_climbed, active_calories, total_calories, sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s, - sleep_score, sleep_start, sleep_end) + sleep_score, sleep_start, sleep_end, sleep_stages) VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr, :avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status, :steps, :floors, :active_cal, :total_cal, :sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake, - :sleep_score, :sleep_start, :sleep_end) + :sleep_score, :sleep_start, :sleep_end, :sleep_stages::json) ON CONFLICT (user_id, date) DO UPDATE SET resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr), avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day), @@ -255,7 +255,8 @@ def parse_wellness_fit(file_path: str, user_id: int): sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s), sleep_score = COALESCE(EXCLUDED.sleep_score, health_metrics.sleep_score), sleep_start = COALESCE(EXCLUDED.sleep_start, health_metrics.sleep_start), - sleep_end = COALESCE(EXCLUDED.sleep_end, health_metrics.sleep_end) + sleep_end = COALESCE(EXCLUDED.sleep_end, health_metrics.sleep_end), + sleep_stages = COALESCE(EXCLUDED.sleep_stages, health_metrics.sleep_stages) """), { "user_id": user_id, "date": date_dt, "resting_hr": data.get("resting_hr"), @@ -278,6 +279,7 @@ def parse_wellness_fit(file_path: str, user_id: int): "sleep_score": data.get("sleep_score"), "sleep_start": data.get("sleep_start"), "sleep_end": data.get("sleep_end"), + "sleep_stages": __import__('json').dumps(data.get("sleep_stages")) if data.get("sleep_stages") else None, }) db.commit() diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx index c8176f7..8066511 100644 --- a/frontend/src/pages/ActivityDetailPage.jsx +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -222,28 +222,33 @@ export default function ActivityDetailPage() {

Segments

Manage β†’ -
+ {/* Column headers */} +
+ Segment + This run + Best + Ξ” +
+
{segments.map(seg => { const t = segmentTime(dataPoints, seg.start_distance_m, seg.end_distance_m) const best = segmentBests?.find(b => b.segment_id === seg.id) const isNewBest = t != null && best?.best_s != null && t <= best.best_s + 0.5 + const delta = t != null && best?.best_s != null ? t - best.best_s : null return ( -
+
{seg.name} - - {t != null ? ( - - {formatDuration(t)} - - ) : ( - -- - )} + + {t != null ? formatDuration(t) : --} + + + {best?.best_s != null ? formatDuration(best.best_s) : '--'} + + + {isNewBest ? 'πŸ†' : delta == null ? '--' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`} - {isNewBest && πŸ†} - {!isNewBest && best?.best_s != null && ( - /{formatDuration(best.best_s)} - )} - {!isNewBest && !best?.best_s && }
) })} diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index a682092..8fd7f50 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -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 }) { )}
- {chartData.length > 0 && ( - <> -
- - - format(new Date(ts), 'HH:mm')} - interval={Math.max(1, Math.floor(chartData.length / 6))} /> - - format(new Date(ts), 'HH:mm')} - formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : null} /> - - {chartData.map((d, i) => ( - - ))} - - - - +
+ + + format(new Date(ts), 'HH:mm')} + interval={Math.max(1, Math.floor(chartData.length / 6))} /> + + format(new Date(ts), 'HH:mm')} + formatter={v => [`${Math.round(v)}`, 'Battery']} /> + + {chartData.map((d, i) => ( + + ))} + + + +
+ +
+ {presentTypes.map(type => ( +
+
+ {BB_INFERRED_LABEL[type]}
-
- {presentTypes.map(code => ( -
-
- {BB_TYPE_LABEL[code]} -
- ))} -
- - )} + ))} +
) } -// 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 ( -
- {/* Stage bars rising from the time axis */} -
- {stages.map(s => ( -
- ))} - {/* Tick lines overlaid on bars */} - {ticks.map((t, i) => ( -
+
+
+ {SLEEP_LANE_ORDER.map(level => ( +
+ + {SLEEP_STAGE_LABEL[level]} + +
+ {segsByLane[level].map((seg, i) => ( +
+ ))} + {ticks.map((t, i) => ( +
+ ))} +
+
))}
- {/* Axis line */} -
- {ticks.map((t, i) => ( -
- ))} -
- {/* Time labels */} -
- +
+ {new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} {ticks.map((t, i) => ( - + {t.label} ))} - + {new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
@@ -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 (

πŸ“Š

@@ -313,12 +323,11 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
{hasSleepStages ? ( <> - -
+
{[ ['Deep', day.sleep_deep_s, '#6366f1'], ['REM', day.sleep_rem_s, '#8b5cf6'], @@ -408,7 +417,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
)} - +
)} @@ -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} diff --git a/frontend/src/pages/RoutesPage.jsx b/frontend/src/pages/RoutesPage.jsx index 856e74b..0373d7c 100644 --- a/frontend/src/pages/RoutesPage.jsx +++ b/frontend/src/pages/RoutesPage.jsx @@ -104,13 +104,15 @@ function decodePolyline(encoded) { return points } -function RouteMap({ polyline, className = '' }) { +function RouteMap({ polyline, className = '', sportType = '' }) { const pts = decodePolyline(polyline) if (pts.length < 2) return (
no track
) + const t = (sportType || '').toLowerCase() + const stroke = (t.includes('cycl') || t.includes('bike') || t.includes('ride')) ? '#f97316' : '#3b82f6' const lats = pts.map(p => p[0]), lngs = pts.map(p => p[1]) const minLat = Math.min(...lats), maxLat = Math.max(...lats) const minLng = Math.min(...lngs), maxLng = Math.max(...lngs) @@ -123,11 +125,20 @@ function RouteMap({ polyline, className = '' }) { const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p[1]).toFixed(1)},${toY(p[0]).toFixed(1)}`).join(' ') return ( - + ) } +function routeSportStyle(sportType) { + const t = (sportType || '').toLowerCase() + if (t.includes('cycl') || t.includes('bike') || t.includes('ride')) + return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400', color: '#f97316' } + if (t.includes('run') || t.includes('jog') || t.includes('walk')) + return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400', color: '#3b82f6' } + return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400', color: '#6b7280' } +} + export default function RoutesPage() { const [selected, setSelected] = useState(null) const [showCreate, setShowCreate] = useState(false) @@ -141,6 +152,9 @@ export default function RoutesPage() { queryFn: () => api.get('/routes/').then(r => r.data), }) + // Sort by most completions first + const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0)) + const { data: routeActivities } = useQuery({ queryKey: ['route-activities', selected?.id], queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data), @@ -241,47 +255,50 @@ export default function RoutesPage() {
)} -
- {/* Route list */} -
- {routes?.length === 0 && !showCreate && ( -
-

πŸ—ΊοΈ

-

No named routes yet

-

Routes are created automatically when you repeat a run, or create one manually above.

-
- )} - {routes?.map(route => ( - - ))} + {/* Route tile grid */} + {routes?.length === 0 && !showCreate ? ( +
+

πŸ—ΊοΈ

+

No named routes yet

+

Routes are created automatically when you repeat a run, or create one manually above.

+ ) : ( +
+ {sortedRoutes.map(route => { + const style = routeSportStyle(route.sport_type) + const isSelected = selected?.id === route.id + return ( + + ) + })} +
+ )} - {/* Route detail */} - {selected && ( -
-
+ {/* Route detail β€” shown below the tile grid when a route is selected */} + {selected && ( +
+
- +

{selected.name}

@@ -351,7 +368,7 @@ export default function RoutesPage() { {/* Activity list */}

- All runs ({routeActivities?.length ?? 0}) + All completions ({routeActivities?.length ?? 0})

{routeActivities?.map((act, i) => ( @@ -373,10 +390,9 @@ export default function RoutesPage() {
-
- )} -
+
+ )}
) }