diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py index 8a2ea8f..eb4e945 100644 --- a/backend/app/services/garmin_connect_sync.py +++ b/backend/app/services/garmin_connect_sync.py @@ -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): diff --git a/backend/app/services/route_matcher.py b/backend/app/services/route_matcher.py index 796df39..d1b9e25 100644 --- a/backend/app/services/route_matcher.py +++ b/backend/app/services/route_matcher.py @@ -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 - 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 + near_start = lambda i: haversine_m(act_coords[i], start_pt) <= tol_m - # Verify the activity actually follows the segment shape between the anchors. - 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 + # 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))] - dur = (act_times[ei] - act_times[si]).total_seconds() - return dur if dur > 0 else None + best = 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( diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 32a7664..4b2124c 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -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'),