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,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user