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: if spo2 and 50 < float(spo2) <= 100:
row["spo2_avg"] = float(spo2) row["spo2_avg"] = float(spo2)
# Sleep score — structure varies across firmware # Sleep score — Garmin nests it under dailySleepDTO.sleepScores on most
scores = sleep_data.get("sleepScores") or sleep_data.get("sleepScore") # 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): if isinstance(scores, dict):
overall = scores.get("overall") or scores.get("qualityScore") overall = scores.get("overall") or scores.get("qualityScore")
if isinstance(overall, dict): if isinstance(overall, dict):
+45 -29
View File
@@ -102,49 +102,65 @@ def match_segment_in_activity(
tol_m: float = 30.0, tol_m: float = 30.0,
) -> Optional[float]: ) -> Optional[float]:
""" """
Determine whether an activity track traverses a segment's GPS geometry, and if so Determine whether an activity track traverses a segment's GPS geometry in the
how long it took. Works even when the activity's overall route differs — only the segment's own direction, and if so how long the fastest such traversal took.
overlapping stretch matters. Works even when the activity's overall route differs — only the overlapping
stretch matters.
seg_coords: [(lat, lon), ...] segment geometry (start → end). seg_coords: [(lat, lon), ...] segment geometry (start → end).
act_coords: [(lat, lon), ...] activity track, in time order. act_coords: [(lat, lon), ...] activity track, in time order.
act_times: parallel list of datetimes for act_coords. act_times: parallel list of datetimes for act_coords.
Strategy: anchor on the activity point nearest the segment start, then the nearest Strategy: for every pass of the activity near the segment START, walk forward
point (at/after it) to the segment end, then verify a few intermediate segment accumulating path length; accept the traversal only if the activity reaches the
points are each passed within tolerance between those anchors. Returns the time segment END after covering roughly the segment's own length (so an out-and-back
between the start and end anchors, or None if the activity doesn't follow the segment. 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) n = len(act_coords)
if n < 2 or len(seg_coords) < 2: m = len(seg_coords)
if n < 2 or m < 2:
return None return None
start_pt, end_pt = seg_coords[0], seg_coords[-1] start_pt, end_pt = seg_coords[0], seg_coords[-1]
seg_len = sum(haversine_m(seg_coords[k], seg_coords[k + 1]) for k in range(m - 1))
si, sd = None, tol_m if seg_len <= 0:
for i in range(n):
d = haversine_m(act_coords[i], start_pt)
if d < sd:
sd, si = d, i
if si is None:
return None return None
ei, ed = None, tol_m near_start = lambda i: haversine_m(act_coords[i], start_pt) <= tol_m
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
# Verify the activity actually follows the segment shape between the anchors. # One candidate entry per pass through the start region (first point of each run).
for frac in (0.25, 0.5, 0.75): entries = [i for i in range(n) if near_start(i) and (i == 0 or not near_start(i - 1))]
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
dur = (act_times[ei] - act_times[si]).total_seconds() best = None
return dur if dur > 0 else None for si in entries:
path = 0.0
ei = None
for i in range(si + 1, n):
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
# 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 * (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()
if dur > 0 and (best is None or dur < best):
best = dur
return best
def find_best_split_time( 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 rows = [...(recentHealth || [])].sort((a, b) => new Date(b.date) - new Date(a.date))
const pick = f => rows.find(d => d[f] != null)?.[f] ?? null const pick = f => rows.find(d => d[f] != null)?.[f] ?? null
return { 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'), resting_hr: pick('resting_hr'),
sleep_duration_s: pick('sleep_duration_s'), sleep_duration_s: pick('sleep_duration_s'),
hrv_nightly_avg: pick('hrv_nightly_avg'), hrv_nightly_avg: pick('hrv_nightly_avg'),