diff --git a/2026-06-04.zip b/2026-06-04.zip
new file mode 100644
index 0000000..e310848
Binary files /dev/null and b/2026-06-04.zip differ
diff --git a/backend/app/__pycache__/__init__.cpython-313.pyc b/backend/app/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..978b0b4
Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-313.pyc differ
diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc
new file mode 100644
index 0000000..05011ed
Binary files /dev/null and b/backend/app/__pycache__/main.cpython-313.pyc differ
diff --git a/backend/app/api/__pycache__/activities.cpython-313.pyc b/backend/app/api/__pycache__/activities.cpython-313.pyc
new file mode 100644
index 0000000..1fd0da4
Binary files /dev/null and b/backend/app/api/__pycache__/activities.cpython-313.pyc differ
diff --git a/backend/app/api/__pycache__/routes.cpython-313.pyc b/backend/app/api/__pycache__/routes.cpython-313.pyc
new file mode 100644
index 0000000..4b7005d
Binary files /dev/null and b/backend/app/api/__pycache__/routes.cpython-313.pyc differ
diff --git a/backend/app/api/health.py b/backend/app/api/health.py
index 9cf18ec..b72c903 100644
--- a/backend/app/api/health.py
+++ b/backend/app/api/health.py
@@ -144,8 +144,9 @@ async def intraday_health(
)
metric = result.scalar_one_or_none()
return {
- "hr_values": metric.intraday_hr if metric else None,
- "body_battery": metric.body_battery if metric else None,
+ "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,
}
diff --git a/backend/app/models/__pycache__/user.cpython-313.pyc b/backend/app/models/__pycache__/user.cpython-313.pyc
new file mode 100644
index 0000000..13c219a
Binary files /dev/null and b/backend/app/models/__pycache__/user.cpython-313.pyc differ
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
index 63fe126..86aed53 100644
--- a/backend/app/models/user.py
+++ b/backend/app/models/user.py
@@ -244,8 +244,9 @@ class HealthMetric(Base):
active_calories = Column(Float, nullable=True)
total_calories = Column(Float, nullable=True)
spo2_avg = Column(Float, nullable=True)
- 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]...]}
+ 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
__table_args__ = (
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
diff --git a/backend/app/services/__pycache__/__init__.cpython-313.pyc b/backend/app/services/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..67b0df2
Binary files /dev/null and b/backend/app/services/__pycache__/__init__.cpython-313.pyc differ
diff --git a/backend/app/services/__pycache__/route_matcher.cpython-313.pyc b/backend/app/services/__pycache__/route_matcher.cpython-313.pyc
new file mode 100644
index 0000000..85c798c
Binary files /dev/null and b/backend/app/services/__pycache__/route_matcher.cpython-313.pyc differ
diff --git a/backend/app/services/__pycache__/wellness_parser.cpython-313.pyc b/backend/app/services/__pycache__/wellness_parser.cpython-313.pyc
new file mode 100644
index 0000000..9ba770e
Binary files /dev/null and b/backend/app/services/__pycache__/wellness_parser.cpython-313.pyc differ
diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py
index b9a567f..e8b275e 100644
--- a/backend/app/services/garmin_connect_sync.py
+++ b/backend/app/services/garmin_connect_sync.py
@@ -258,12 +258,19 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
row["body_battery"] = _json.dumps(bb)
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs
+ intraday = None
if hr_raw:
raw_vals = hr_raw.get("heartRateValues") or []
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
if intraday:
row["intraday_hr"] = intraday
+ # High-resolution body battery derived from BB checkpoints + intraday HR
+ if bb and intraday:
+ hires = _compute_body_battery_hires(bb.get("values") or [], intraday)
+ if hires:
+ row["body_battery_hires"] = _json.dumps(hires)
+
if not row:
continue
@@ -351,6 +358,72 @@ def _parse_body_battery(bb_response, day_str: str):
}
+def _compute_body_battery_hires(bb_values, intraday_hr):
+ """
+ Produce a higher-resolution body battery series by interpolating between
+ sparse BB checkpoints using intraday HR as a proxy for effort.
+
+ During drain segments (BB falling) the drain is distributed proportionally
+ to how much each HR reading exceeds the day's median — peaks spend battery
+ faster than valleys. During recovery segments (BB rising) recovery is
+ spread uniformly over time.
+
+ Returns [[ts_ms, level], ...] at the granularity of intraday HR, or None
+ if inputs are insufficient.
+ """
+ if not bb_values or not intraday_hr or len(bb_values) < 2:
+ return None
+
+ bb = sorted(bb_values, key=lambda x: x[0])
+ hr = sorted(intraday_hr, key=lambda x: x[0])
+
+ hr_vals = [bpm for _, bpm in hr if bpm is not None and bpm > 0]
+ if not hr_vals:
+ return None
+
+ hr_median = sorted(hr_vals)[len(hr_vals) // 2]
+
+ result = []
+ for i in range(len(bb) - 1):
+ t1, L1 = bb[i][0], bb[i][1]
+ t2, L2 = bb[i + 1][0], bb[i + 1][1]
+ delta = L2 - L1
+
+ seg_hr = [(ts, bpm) for ts, bpm in hr if t1 <= ts <= t2 and bpm is not None]
+ result.append([t1, round(float(L1), 1)])
+
+ if not seg_hr or abs(delta) < 1:
+ continue
+
+ if delta < 0:
+ # Drain: weight each reading by HR above median
+ efforts = [max(0.0, bpm - hr_median) for _, bpm in seg_hr]
+ total = sum(efforts) or 1.0
+ cumul = 0.0
+ for j, (ts, bpm) in enumerate(seg_hr):
+ cumul += efforts[j] * delta / total
+ level = max(0.0, min(100.0, L1 + cumul))
+ result.append([ts, round(level, 1)])
+ else:
+ # Recovery: linear over time
+ span = max(1, t2 - t1)
+ for ts, _ in seg_hr:
+ frac = (ts - t1) / span
+ level = max(0.0, min(100.0, L1 + delta * frac))
+ result.append([ts, round(level, 1)])
+
+ result.append([bb[-1][0], round(float(bb[-1][1]), 1)])
+
+ # Deduplicate and sort
+ seen, out = set(), []
+ for item in sorted(result, key=lambda x: x[0]):
+ if item[0] not in seen:
+ seen.add(item[0])
+ out.append(item)
+
+ return out if len(out) > 4 else None
+
+
def _safe(fn, *args):
try:
return fn(*args)
diff --git a/backend/app/workers/__pycache__/tasks.cpython-313.pyc b/backend/app/workers/__pycache__/tasks.cpython-313.pyc
new file mode 100644
index 0000000..dc77078
Binary files /dev/null and b/backend/app/workers/__pycache__/tasks.cpython-313.pyc differ
diff --git a/frontend/src/components/activity/MetricTimeline.jsx b/frontend/src/components/activity/MetricTimeline.jsx
index 2ca95a7..36d2a29 100644
--- a/frontend/src/components/activity/MetricTimeline.jsx
+++ b/frontend/src/components/activity/MetricTimeline.jsx
@@ -100,7 +100,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
{metric.unit && ({metric.unit})}
-
+
({
- t: ts,
- level,
- type: type ?? 4,
- bar: 100,
+ // 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
+ ? hiresValues.map(([ts, level]) => ({ t: ts, level }))
+ : (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,
}))
- const presentTypes = [...new Set(chartData.map(d => d.type))]
+ const presentTypes = [...new Set(bgData.map(d => d.type))]
const levelColor = bbLevelColor(end_level)
return (
@@ -113,12 +125,13 @@ function BodyBatteryChart({ bb }) {
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) => (
- |
+ |
))}
📊
@@ -334,7 +347,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, onOlder, onNewer,
)}
-
+
)}
@@ -580,6 +593,7 @@ export default function HealthPage() {
avg30={summary?.avg_30d}
intradayHr={intradayData?.hr_values}
bodyBattery={intradayData?.body_battery}
+ bbHires={intradayData?.body_battery_hires}
onOlder={goOlder}
onNewer={goNewer}
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}