Health hypnogram, routes tiles, BB bar chart, segment delta
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s

- 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:
2026-06-07 18:44:00 +01:00
parent 492418586a
commit 67fd4b3c96
8 changed files with 208 additions and 169 deletions
+1
View File
@@ -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,
}
+1
View File
@@ -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:
+1
View File
@@ -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"),
+3
View File
@@ -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}
+5 -3
View File
@@ -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()