Fix sleep score parsing, dashboard body battery, segment direction
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 4s
Build and push images / build-frontend (push) Successful in 9s

- Garmin sync: read sleepScores from dailySleepDTO (Garmin nests it there),
  so sleep score is actually stored instead of always null
- Dashboard: pass YYYY-MM-DD to the intraday endpoint (was a full ISO
  timestamp), so the body-battery tile populates
- Segment matching: follow the segment in its created direction with a
  path-length sanity check, so out-and-back routes no longer match an early
  start pass to a late finish (the >1h bogus segment times)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 20:58:53 +01:00
parent 0aa27713ca
commit 6a1726e0c3
3 changed files with 50 additions and 32 deletions
+4 -2
View File
@@ -555,8 +555,10 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
if spo2 and 50 < float(spo2) <= 100:
row["spo2_avg"] = float(spo2)
# Sleep score — structure varies across firmware
scores = sleep_data.get("sleepScores") or sleep_data.get("sleepScore")
# Sleep score — Garmin nests it under dailySleepDTO.sleepScores on most
# firmware, but some return it at the top level; check both.
scores = (dto.get("sleepScores") or sleep_data.get("sleepScores")
or dto.get("sleepScore") or sleep_data.get("sleepScore"))
if isinstance(scores, dict):
overall = scores.get("overall") or scores.get("qualityScore")
if isinstance(overall, dict):
+42 -26
View File
@@ -102,49 +102,65 @@ def match_segment_in_activity(
tol_m: float = 30.0,
) -> Optional[float]:
"""
Determine whether an activity track traverses a segment's GPS geometry, and if so
how long it took. Works even when the activity's overall route differs — only the
overlapping stretch matters.
Determine whether an activity track traverses a segment's GPS geometry in the
segment's own direction, and if so how long the fastest such traversal took.
Works even when the activity's overall route differs — only the overlapping
stretch matters.
seg_coords: [(lat, lon), ...] segment geometry (start → end).
act_coords: [(lat, lon), ...] activity track, in time order.
act_times: parallel list of datetimes for act_coords.
Strategy: anchor on the activity point nearest the segment start, then the nearest
point (at/after it) to the segment end, then verify a few intermediate segment
points are each passed within tolerance between those anchors. Returns the time
between the start and end anchors, or None if the activity doesn't follow the segment.
Strategy: for every pass of the activity near the segment START, walk forward
accumulating path length; accept the traversal only if the activity reaches the
segment END after covering roughly the segment's own length (so an out-and-back
route can't match an early start to a late finish), and the intermediate segment
points are passed in order. Returns the shortest valid traversal time, or None.
"""
n = len(act_coords)
if n < 2 or len(seg_coords) < 2:
m = len(seg_coords)
if n < 2 or m < 2:
return None
start_pt, end_pt = seg_coords[0], seg_coords[-1]
si, sd = None, tol_m
for i in range(n):
d = haversine_m(act_coords[i], start_pt)
if d < sd:
sd, si = d, i
if si is None:
seg_len = sum(haversine_m(seg_coords[k], seg_coords[k + 1]) for k in range(m - 1))
if seg_len <= 0:
return None
ei, ed = None, tol_m
near_start = lambda i: haversine_m(act_coords[i], start_pt) <= tol_m
# One candidate entry per pass through the start region (first point of each run).
entries = [i for i in range(n) if near_start(i) and (i == 0 or not near_start(i - 1))]
best = None
for si in entries:
path = 0.0
ei = None
for i in range(si + 1, n):
d = haversine_m(act_coords[i], end_pt)
if d < ed:
ed, ei = d, i
if ei is None or ei <= si:
return None
path += haversine_m(act_coords[i - 1], act_coords[i])
if path > seg_len * 1.5: # wandered too far without finishing → wrong pass/direction
break
if path >= seg_len * 0.6 and haversine_m(act_coords[i], end_pt) <= tol_m:
ei = i
break
if ei is None:
continue
# Verify the activity actually follows the segment shape between the anchors.
# Confirm the activity follows the segment shape in order between the anchors.
ok = True
for frac in (0.25, 0.5, 0.75):
sp = seg_coords[int(frac * (len(seg_coords) - 1))]
if not any(haversine_m(act_coords[i], sp) <= tol_m for i in range(si, ei + 1)):
return None
sp = seg_coords[int(frac * (m - 1))]
if not any(haversine_m(act_coords[k], sp) <= tol_m for k in range(si, ei + 1)):
ok = False
break
if not ok:
continue
dur = (act_times[ei] - act_times[si]).total_seconds()
return dur if dur > 0 else None
if dur > 0 and (best is None or dur < best):
best = dur
return best
def find_best_split_time(
+1 -1
View File
@@ -152,7 +152,7 @@ export default function DashboardPage() {
const rows = [...(recentHealth || [])].sort((a, b) => new Date(b.date) - new Date(a.date))
const pick = f => rows.find(d => d[f] != null)?.[f] ?? null
return {
date: rows[0]?.date ?? null,
date: rows[0]?.date ? rows[0].date.slice(0, 10) : null, // intraday endpoint wants YYYY-MM-DD
resting_hr: pick('resting_hr'),
sleep_duration_s: pick('sleep_duration_s'),
hrv_nightly_avg: pick('hrv_nightly_avg'),