Body
Build and push images / validate (push) Successful in 19s
Build and push images / build-backend (push) Successful in 1m15s
Build and push images / build-worker (push) Successful in 1m13s
Build and push images / build-frontend (push) Successful in 51s

This commit is contained in:
2026-06-07 15:26:54 +01:00
parent 568dc31e97
commit da9c1e04cb
15 changed files with 104 additions and 15 deletions
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -2
View File
@@ -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,
}
Binary file not shown.
+3 -2
View File
@@ -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"),
@@ -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)
@@ -100,7 +100,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
</div>
<ResponsiveContainer width="100%" height={100}>
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}>
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }} syncId="activity-metrics">
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis
dataKey="distance_m"
+24 -10
View File
@@ -71,19 +71,31 @@ function bbLevelColor(level) {
return '#ef4444'
}
function BodyBatteryChart({ bb }) {
function BodyBatteryChart({ bb, hiresValues }) {
if (!bb) return null
const { charged, drained, start_level, end_level, values } = bb
if (!values?.length && end_level == null) return null
const chartData = (values || []).map(([ts, level, type]) => ({
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 }) {
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
interval={Math.max(1, Math.floor(chartData.length / 6))} />
<YAxis domain={[0, 100]} hide />
<Tooltip contentStyle={tooltipStyle}
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={(v, name) => name === 'level' ? [`${Math.round(v)}`, 'Battery'] : null} />
<Bar dataKey="bar" isAnimationActive={false} maxBarSize={6}>
{chartData.map((d, i) => (
<Cell key={i} fill={BB_TYPE_COLOR[d.type] ?? '#374151'} fillOpacity={0.8} />
<Cell key={i} fill={d.type != null ? (BB_TYPE_COLOR[d.type] ?? '#374151') : 'transparent'} fillOpacity={0.8} />
))}
</Bar>
<Line type="monotone" dataKey="level" stroke="#e5e7eb" strokeWidth={1.5}
@@ -180,7 +193,7 @@ function NavArrow({ onClick, disabled, children }) {
)
}
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, onOlder, onNewer, hasOlder, hasNewer }) {
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder, onNewer, hasOlder, hasNewer }) {
if (!day) return (
<div className="text-center py-10 text-gray-600">
<p className="text-3xl mb-2">📊</p>
@@ -334,7 +347,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, onOlder, onNewer,
</div>
</div>
)}
<BodyBatteryChart bb={bodyBattery} />
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} />
</div>
)}
@@ -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}