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:
@@ -147,6 +147,7 @@ async def intraday_health(
|
|||||||
"hr_values": metric.intraday_hr if metric else None,
|
"hr_values": metric.intraday_hr if metric else None,
|
||||||
"body_battery": metric.body_battery if metric else None,
|
"body_battery": metric.body_battery if metric else None,
|
||||||
"body_battery_hires": metric.body_battery_hires if metric else None,
|
"body_battery_hires": metric.body_battery_hires if metric else None,
|
||||||
|
"sleep_stages": metric.sleep_stages if metric else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 intraday_hr JSONB",
|
||||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery 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))
|
await conn.execute(text(stmt))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ class HealthMetric(Base):
|
|||||||
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response
|
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 = 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
|
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__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
|
||||||
|
|||||||
@@ -319,8 +319,10 @@ def parse_wellness_fit(file_path: str) -> dict:
|
|||||||
sleep_rem_s = level_secs[4] or None
|
sleep_rem_s = level_secs[4] or None
|
||||||
sleep_awake_s = level_secs[1] 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_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:
|
else:
|
||||||
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
|
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")
|
active_cal = data.get("active_calories")
|
||||||
bmr = data.get("bmr")
|
bmr = data.get("bmr")
|
||||||
@@ -348,6 +350,7 @@ def parse_wellness_fit(file_path: str) -> dict:
|
|||||||
"sleep_score": data.get("sleep_score"),
|
"sleep_score": data.get("sleep_score"),
|
||||||
"sleep_start": sleep_start_ts,
|
"sleep_start": sleep_start_ts,
|
||||||
"sleep_end": sleep_end_ts,
|
"sleep_end": sleep_end_ts,
|
||||||
|
"sleep_stages": sleep_stages,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"days": result, "error": None}
|
return {"days": result, "error": None}
|
||||||
|
|||||||
@@ -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,
|
avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status,
|
||||||
steps, floors_climbed, active_calories, total_calories,
|
steps, floors_climbed, active_calories, total_calories,
|
||||||
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s,
|
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,
|
VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
|
||||||
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
|
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
|
||||||
:steps, :floors, :active_cal, :total_cal,
|
:steps, :floors, :active_cal, :total_cal,
|
||||||
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake,
|
: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
|
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||||
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
|
||||||
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
|
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_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s),
|
||||||
sleep_score = COALESCE(EXCLUDED.sleep_score, health_metrics.sleep_score),
|
sleep_score = COALESCE(EXCLUDED.sleep_score, health_metrics.sleep_score),
|
||||||
sleep_start = COALESCE(EXCLUDED.sleep_start, health_metrics.sleep_start),
|
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,
|
"user_id": user_id, "date": date_dt,
|
||||||
"resting_hr": data.get("resting_hr"),
|
"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_score": data.get("sleep_score"),
|
||||||
"sleep_start": data.get("sleep_start"),
|
"sleep_start": data.get("sleep_start"),
|
||||||
"sleep_end": data.get("sleep_end"),
|
"sleep_end": data.get("sleep_end"),
|
||||||
|
"sleep_stages": __import__('json').dumps(data.get("sleep_stages")) if data.get("sleep_stages") else None,
|
||||||
})
|
})
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -222,28 +222,33 @@ export default function ActivityDetailPage() {
|
|||||||
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
|
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
|
||||||
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage →</Link>
|
<Link to="/segments" className="text-xs text-blue-400 hover:underline">Manage →</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
{/* Column headers */}
|
||||||
|
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 mb-1">
|
||||||
|
<span className="flex-1 text-xs text-gray-600 uppercase tracking-wide">Segment</span>
|
||||||
|
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">This run</span>
|
||||||
|
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Best</span>
|
||||||
|
<span className="font-mono text-xs w-14 text-right text-gray-600 uppercase tracking-wide">Δ</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
{segments.map(seg => {
|
{segments.map(seg => {
|
||||||
const t = segmentTime(dataPoints, seg.start_distance_m, seg.end_distance_m)
|
const t = segmentTime(dataPoints, seg.start_distance_m, seg.end_distance_m)
|
||||||
const best = segmentBests?.find(b => b.segment_id === seg.id)
|
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 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 (
|
return (
|
||||||
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/50 text-sm">
|
<div key={seg.id} className="flex items-center gap-3 py-1.5 border-b border-gray-800/40 text-sm">
|
||||||
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
|
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
|
||||||
<span className="font-mono text-xs w-14 text-right">
|
<span className={`font-mono text-xs w-14 text-right ${isNewBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
|
||||||
{t != null ? (
|
{t != null ? formatDuration(t) : <span className="text-gray-700">--</span>}
|
||||||
<span className={isNewBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}>
|
</span>
|
||||||
{formatDuration(t)}
|
<span className="font-mono text-xs w-14 text-right text-gray-500">
|
||||||
</span>
|
{best?.best_s != null ? formatDuration(best.best_s) : '--'}
|
||||||
) : (
|
</span>
|
||||||
<span className="text-gray-700">--</span>
|
<span className={`font-mono text-xs w-14 text-right ${
|
||||||
)}
|
isNewBest ? 'text-yellow-400' : delta == null ? 'text-gray-700' : delta <= 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{isNewBest ? '🏆' : delta == null ? '--' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`}
|
||||||
</span>
|
</span>
|
||||||
{isNewBest && <span className="text-xs" title="New best">🏆</span>}
|
|
||||||
{!isNewBest && best?.best_s != null && (
|
|
||||||
<span className="text-gray-600 text-xs w-14 text-right">/{formatDuration(best.best_s)}</span>
|
|
||||||
)}
|
|
||||||
{!isNewBest && !best?.best_s && <span className="w-14" />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
+118
-108
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
import { useQuery, keepPreviousData } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, BarChart, Bar, ComposedChart, Line, ReferenceLine,
|
AreaChart, Area, BarChart, Bar, ReferenceLine,
|
||||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { format, subDays } from 'date-fns'
|
import { format, subDays } from 'date-fns'
|
||||||
@@ -60,8 +60,18 @@ function IntradayHrChart({ values }) {
|
|||||||
|
|
||||||
// ── Body Battery ─────────────────────────────────────────────────────────────
|
// ── Body Battery ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const BB_TYPE_COLOR = { 0: '#3b82f6', 1: '#6b7280', 2: '#1e3a5f', 3: '#f97316', 4: '#374151' }
|
const BB_INFERRED_COLOR = {
|
||||||
const BB_TYPE_LABEL = { 0: 'Rest', 1: 'Active', 2: 'Sleep', 3: 'Stress', 4: 'Unmeasurable' }
|
sleep: '#4f46e5',
|
||||||
|
rest: '#0d9488',
|
||||||
|
activity: '#f97316',
|
||||||
|
stable: '#374151',
|
||||||
|
}
|
||||||
|
const BB_INFERRED_LABEL = {
|
||||||
|
sleep: 'Sleep',
|
||||||
|
rest: 'Rest',
|
||||||
|
activity: 'Active/Stress',
|
||||||
|
stable: 'Stable',
|
||||||
|
}
|
||||||
|
|
||||||
function bbLevelColor(level) {
|
function bbLevelColor(level) {
|
||||||
if (level == null) return '#6b7280'
|
if (level == null) return '#6b7280'
|
||||||
@@ -71,31 +81,36 @@ function bbLevelColor(level) {
|
|||||||
return '#ef4444'
|
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
|
if (!bb) return null
|
||||||
const { charged, drained, start_level, end_level, values } = bb
|
const { charged, drained, start_level, end_level } = bb
|
||||||
if (!values?.length && end_level == null) return null
|
if (!hiresValues?.length && !bb.values?.length && end_level == null) return null
|
||||||
|
|
||||||
// Background bars use the raw checkpoint type codes to colour activity segments.
|
const rawData = hiresValues?.length
|
||||||
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
|
|
||||||
? hiresValues.map(([ts, level]) => ({ t: ts, level }))
|
? 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.
|
if (!rawData.length) return null
|
||||||
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 sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
|
||||||
const lineMap = Object.fromEntries(lineData.map(d => [d.t, d]))
|
const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
|
||||||
const chartData = [...tsSet].sort((a, b) => a - b).map(t => ({
|
|
||||||
t,
|
const chartData = rawData.map((d, i) => ({
|
||||||
bar: bgMap[t]?.bar ?? null,
|
...d,
|
||||||
type: bgMap[t]?.type ?? null,
|
type: inferBBType(d.t, d.level, i > 0 ? rawData[i - 1].level : null, sleepStartMs, sleepEndMs),
|
||||||
level: lineMap[t]?.level ?? null,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const presentTypes = [...new Set(bgData.map(d => d.type))]
|
const presentTypes = [...new Set(chartData.map(d => d.type))]
|
||||||
const levelColor = bbLevelColor(end_level)
|
const levelColor = bbLevelColor(end_level)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -117,109 +132,104 @@ function BodyBatteryChart({ bb, hiresValues }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chartData.length > 0 && (
|
<div className="flex-1">
|
||||||
<>
|
<ResponsiveContainer width="100%" height={100}>
|
||||||
<div className="flex-1">
|
<BarChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 0 }} barCategoryGap={0}>
|
||||||
<ResponsiveContainer width="100%" height={100}>
|
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||||
<ComposedChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 0 }}>
|
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
||||||
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
<YAxis domain={[0, 100]} hide />
|
||||||
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
<Tooltip contentStyle={tooltipStyle}
|
||||||
<YAxis domain={[0, 100]} hide />
|
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||||
<Tooltip contentStyle={tooltipStyle}
|
formatter={v => [`${Math.round(v)}`, 'Battery']} />
|
||||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
<Bar dataKey="level" isAnimationActive={false} radius={0}>
|
||||||
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : null} />
|
{chartData.map((d, i) => (
|
||||||
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={6}>
|
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
||||||
{chartData.map((d, i) => (
|
))}
|
||||||
<Cell key={i} fill={d.type != null ? (BB_TYPE_COLOR[d.type] ?? '#374151') : 'transparent'} fillOpacity={0.8} />
|
</Bar>
|
||||||
))}
|
</BarChart>
|
||||||
</Bar>
|
</ResponsiveContainer>
|
||||||
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={1.5}
|
</div>
|
||||||
dot={false} isAnimationActive={false} connectNulls />
|
|
||||||
</ComposedChart>
|
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||||
</ResponsiveContainer>
|
{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 className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
))}
|
||||||
{presentTypes.map(code => (
|
</div>
|
||||||
<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
|
// Proper sleep hypnogram: 4 horizontal lanes (Awake/REM/Light/Deep), time on X axis
|
||||||
function SleepTimeline({ sleepStart, sleepEnd, deep, light, rem, awake }) {
|
const SLEEP_LANE_ORDER = [1, 4, 2, 3] // top→bottom: awake, rem, light, deep
|
||||||
if (!sleepStart || !sleepEnd) return null
|
const SLEEP_STAGE_COLOR = { 0: '#6b7280', 1: '#eab308', 2: '#a78bfa', 3: '#6366f1', 4: '#8b5cf6' }
|
||||||
const stageSecs = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
|
const SLEEP_STAGE_LABEL = { 1: 'Awake', 2: 'Light', 3: 'Deep', 4: 'REM' }
|
||||||
if (!stageSecs) return null
|
const LANE_H = 15
|
||||||
|
|
||||||
|
function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
|
||||||
|
if (!sleepStart || !sleepEnd || !stages?.length) return null
|
||||||
const startMs = new Date(sleepStart).getTime()
|
const startMs = new Date(sleepStart).getTime()
|
||||||
const endMs = new Date(sleepEnd).getTime()
|
const endMs = new Date(sleepEnd).getTime()
|
||||||
const windowMs = endMs - startMs
|
const windowMs = endMs - startMs
|
||||||
if (windowMs <= 0) return null
|
if (windowMs <= 0) return null
|
||||||
|
|
||||||
// Build stage segments proportional to duration, but rendered across the sleep window
|
// Build segments per lane
|
||||||
const stages = [
|
const segsByLane = {}
|
||||||
{ key: 'deep', secs: deep || 0, color: '#6366f1', label: 'Deep' },
|
SLEEP_LANE_ORDER.forEach(lv => { segsByLane[lv] = [] })
|
||||||
{ key: 'rem', secs: rem || 0, color: '#8b5cf6', label: 'REM' },
|
stages.forEach(([tsMs, level], i) => {
|
||||||
{ key: 'light', secs: light || 0, color: '#a78bfa', label: 'Light' },
|
if (!(level in segsByLane)) return
|
||||||
{ key: 'awake', secs: awake || 0, color: '#eab308', label: 'Awake' },
|
const nextTs = i + 1 < stages.length ? stages[i + 1][0] : endMs
|
||||||
].filter(s => s.secs > 0)
|
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
|
// Hour ticks
|
||||||
const startHour = new Date(startMs)
|
const sh = new Date(startMs); sh.setMinutes(0, 0, 0); sh.setHours(sh.getHours() + 1)
|
||||||
startHour.setMinutes(0, 0, 0)
|
|
||||||
startHour.setHours(startHour.getHours() + 1)
|
|
||||||
const ticks = []
|
const ticks = []
|
||||||
let tick = startHour.getTime()
|
for (let t = sh.getTime(); t < endMs; t += 3600000) {
|
||||||
while (tick < endMs) {
|
const pct = (t - startMs) / windowMs * 100
|
||||||
const pct = Math.min(100, Math.max(0, (tick - startMs) / windowMs * 100))
|
if (pct >= 0 && pct <= 100)
|
||||||
ticks.push({ pct, label: new Date(tick).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) })
|
ticks.push({ pct, label: new Date(t).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) })
|
||||||
tick += 3600000
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="pl-10">
|
||||||
{/* Stage bars rising from the time axis */}
|
<div className="space-y-px">
|
||||||
<div className="relative flex overflow-hidden rounded-t-sm" style={{ height: 48 }}>
|
{SLEEP_LANE_ORDER.map(level => (
|
||||||
{stages.map(s => (
|
<div key={level} className="relative flex items-center">
|
||||||
<div
|
<span className="absolute right-full pr-1.5 text-gray-500 whitespace-nowrap select-none"
|
||||||
key={s.key}
|
style={{ fontSize: 10 }}>
|
||||||
title={`${s.label}: ${Math.round(s.secs / 60)} min`}
|
{SLEEP_STAGE_LABEL[level]}
|
||||||
style={{ width: `${(s.secs / stageSecs * 100).toFixed(2)}%`, backgroundColor: s.color }}
|
</span>
|
||||||
/>
|
<div className="relative flex-1 rounded-sm overflow-hidden bg-gray-800/50" style={{ height: LANE_H }}>
|
||||||
))}
|
{segsByLane[level].map((seg, i) => (
|
||||||
{/* Tick lines overlaid on bars */}
|
<div key={i} className="absolute top-0 h-full"
|
||||||
{ticks.map((t, i) => (
|
style={{ left: `${seg.left}%`, width: `${seg.w}%`, backgroundColor: SLEEP_STAGE_COLOR[level] }} />
|
||||||
<div key={i} className="absolute top-0 bottom-0 w-px bg-black/25 pointer-events-none"
|
))}
|
||||||
style={{ left: `${t.pct}%` }} />
|
{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>
|
||||||
{/* Axis line */}
|
<div className="relative h-4 mt-1 ml-0">
|
||||||
<div className="border-t border-gray-600 relative">
|
<span className="absolute left-0 text-gray-500" style={{ fontSize: 10 }}>
|
||||||
{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">
|
|
||||||
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
{ticks.map((t, i) => (
|
{ticks.map((t, i) => (
|
||||||
<span key={i} className="absolute text-xs text-gray-600"
|
<span key={i} className="absolute text-gray-600"
|
||||||
style={{ left: `${t.pct}%`, transform: 'translateX(-50%)' }}>
|
style={{ left: `${t.pct}%`, transform: 'translateX(-50%)', fontSize: 10 }}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</span>
|
</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' })}
|
{new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 (
|
if (!day) return (
|
||||||
<div className="text-center py-10 text-gray-600">
|
<div className="text-center py-10 text-gray-600">
|
||||||
<p className="text-3xl mb-2">📊</p>
|
<p className="text-3xl mb-2">📊</p>
|
||||||
@@ -313,12 +323,11 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
|
|||||||
</div>
|
</div>
|
||||||
{hasSleepStages ? (
|
{hasSleepStages ? (
|
||||||
<>
|
<>
|
||||||
<SleepTimeline
|
<SleepHypnogram
|
||||||
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
|
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
|
||||||
deep={day.sleep_deep_s} light={day.sleep_light_s}
|
stages={sleepStages}
|
||||||
rem={day.sleep_rem_s} awake={day.sleep_awake_s}
|
|
||||||
/>
|
/>
|
||||||
<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'],
|
['Deep', day.sleep_deep_s, '#6366f1'],
|
||||||
['REM', day.sleep_rem_s, '#8b5cf6'],
|
['REM', day.sleep_rem_s, '#8b5cf6'],
|
||||||
@@ -408,7 +417,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} />
|
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -657,6 +666,7 @@ export default function HealthPage() {
|
|||||||
intradayHr={intradayData?.hr_values}
|
intradayHr={intradayData?.hr_values}
|
||||||
bodyBattery={intradayData?.body_battery}
|
bodyBattery={intradayData?.body_battery}
|
||||||
bbHires={intradayData?.body_battery_hires}
|
bbHires={intradayData?.body_battery_hires}
|
||||||
|
sleepStages={intradayData?.sleep_stages}
|
||||||
onOlder={goOlder}
|
onOlder={goOlder}
|
||||||
onNewer={goNewer}
|
onNewer={goNewer}
|
||||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||||
|
|||||||
@@ -104,13 +104,15 @@ function decodePolyline(encoded) {
|
|||||||
return points
|
return points
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteMap({ polyline, className = '' }) {
|
function RouteMap({ polyline, className = '', sportType = '' }) {
|
||||||
const pts = decodePolyline(polyline)
|
const pts = decodePolyline(polyline)
|
||||||
if (pts.length < 2) return (
|
if (pts.length < 2) return (
|
||||||
<div className={`bg-gray-800 rounded flex items-center justify-center text-gray-600 text-xs ${className}`}>
|
<div className={`bg-gray-800 rounded flex items-center justify-center text-gray-600 text-xs ${className}`}>
|
||||||
no track
|
no track
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
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 lats = pts.map(p => p[0]), lngs = pts.map(p => p[1])
|
||||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||||
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
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(' ')
|
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p[1]).toFixed(1)},${toY(p[0]).toFixed(1)}`).join(' ')
|
||||||
return (
|
return (
|
||||||
<svg viewBox={`0 0 ${w} ${h}`} className={`bg-gray-800 rounded ${className}`} xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox={`0 0 ${w} ${h}`} className={`bg-gray-800 rounded ${className}`} xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d={d} fill="none" stroke="#3b82f6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
<path d={d} fill="none" stroke={stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function RoutesPage() {
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
@@ -141,6 +152,9 @@ export default function RoutesPage() {
|
|||||||
queryFn: () => api.get('/routes/').then(r => r.data),
|
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({
|
const { data: routeActivities } = useQuery({
|
||||||
queryKey: ['route-activities', selected?.id],
|
queryKey: ['route-activities', selected?.id],
|
||||||
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
|
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
|
||||||
@@ -241,47 +255,50 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
{/* Route tile grid */}
|
||||||
{/* Route list */}
|
{routes?.length === 0 && !showCreate ? (
|
||||||
<div className="space-y-2">
|
<div className="text-center py-12 text-gray-600">
|
||||||
{routes?.length === 0 && !showCreate && (
|
<p className="text-3xl mb-2">🗺️</p>
|
||||||
<div className="text-center py-12 text-gray-600">
|
<p className="text-sm">No named routes yet</p>
|
||||||
<p className="text-3xl mb-2">🗺️</p>
|
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
||||||
<p className="text-sm">No named routes yet</p>
|
|
||||||
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{routes?.map(route => (
|
|
||||||
<button key={route.id} onClick={() => { setSelected(route); setMerging(false) }}
|
|
||||||
className={`w-full text-left p-3 rounded-xl border transition-all ${
|
|
||||||
selected?.id === route.id ? 'bg-blue-900/20 border-blue-700' : 'bg-gray-900 border-gray-800 hover:border-gray-600'
|
|
||||||
}`}>
|
|
||||||
<div className="flex gap-3 items-start">
|
|
||||||
<RouteMap polyline={route.reference_polyline} className="w-20 h-12 flex-shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-start justify-between gap-1">
|
|
||||||
<p className="font-medium text-white text-sm truncate">{route.name}</p>
|
|
||||||
{route.auto_detected && (
|
|
||||||
<span className="text-xs bg-gray-800 text-gray-400 px-1.5 py-0.5 rounded-full flex-shrink-0">auto</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 mt-0.5 text-xs text-gray-500">
|
|
||||||
<span>{formatDistance(route.distance_m)}</span>
|
|
||||||
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||||
|
{sortedRoutes.map(route => {
|
||||||
|
const style = routeSportStyle(route.sport_type)
|
||||||
|
const isSelected = selected?.id === route.id
|
||||||
|
return (
|
||||||
|
<button key={route.id}
|
||||||
|
onClick={() => { setSelected(isSelected ? null : route); setMerging(false) }}
|
||||||
|
className={`text-left rounded-xl border p-2 transition-all ${
|
||||||
|
isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600`
|
||||||
|
}`}>
|
||||||
|
<RouteMap polyline={route.reference_polyline} className="w-full h-20" sportType={route.sport_type} />
|
||||||
|
<p className="text-xs font-medium text-white mt-2 truncate">{route.name}</p>
|
||||||
|
<div className="flex items-center justify-between mt-0.5 gap-1">
|
||||||
|
<span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span>
|
||||||
|
{route.activity_count > 0 && (
|
||||||
|
<span className={`text-xs font-medium ${style.accent}`}>
|
||||||
|
{route.activity_count}×
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{route.auto_detected && (
|
||||||
|
<span className="text-xs text-gray-600">auto</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Route detail */}
|
{/* Route detail — shown below the tile grid when a route is selected */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="flex gap-4 items-start">
|
<div className="flex gap-4 items-start">
|
||||||
<RouteMap polyline={selected.reference_polyline} className="w-36 h-24 flex-shrink-0" />
|
<RouteMap polyline={selected.reference_polyline} className="w-36 h-24 flex-shrink-0" sportType={selected.sport_type} />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
<h2 className="text-lg font-semibold text-white">{selected.name}</h2>
|
||||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
|
||||||
@@ -351,7 +368,7 @@ export default function RoutesPage() {
|
|||||||
|
|
||||||
{/* Activity list */}
|
{/* Activity list */}
|
||||||
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
<h3 className="text-sm font-medium text-gray-400 mb-2">
|
||||||
All runs ({routeActivities?.length ?? 0})
|
All completions ({routeActivities?.length ?? 0})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{routeActivities?.map((act, i) => (
|
{routeActivities?.map((act, i) => (
|
||||||
@@ -373,10 +390,9 @@ export default function RoutesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
|
<SegmentsPanel routeId={selected.id} sportType={selected.sport_type} />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user