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() {
-
- )}
-
+
+ )}
)
}