From 635e9b64c5aa29de3577d3dfbffa50829729c5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:55:11 +0200 Subject: [PATCH] fix(workouts): enrich canonical WHOOP aggregates --- .../pick-canonical-workout-rows.test.ts | 55 +++++++++ .../pick-canonical-workout-rows.ts | 106 +++++++++++++++++- 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts index b8bba9328..7599994e8 100644 --- a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts +++ b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts @@ -4,6 +4,7 @@ import { pickCanonicalWorkoutRows } from "../pick-canonical-workout-rows"; interface RowFixture { id: string; + userId?: string; startedAt: Date; sportType: string; source: "APPLE_HEALTH" | "WHOOP" | "WITHINGS" | "MANUAL" | "IMPORT"; @@ -227,6 +228,60 @@ describe("pickCanonicalWorkoutRows", () => { }); }); + it("does not collapse or enrich rows belonging to different users", () => { + const result = pickCanonicalWorkoutRows([ + { + id: "apple-u1", + userId: "u1", + startedAt: new Date("2026-06-03T06:30:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + avgHeartRate: null, + }, + { + id: "whoop-u2", + userId: "u2", + startedAt: new Date("2026-06-03T06:30:30Z"), + sportType: "running", + source: "WHOOP", + avgHeartRate: 150, + }, + ]); + + expect(result).toHaveLength(2); + expect(result.find((row) => row.id === "apple-u1")?.avgHeartRate).toBeNull(); + }); + + it("uses each WHOOP twin at most once for same-slot occurrences", () => { + const result = pickCanonicalWorkoutRows([ + { + id: "apple-near", + startedAt: new Date("2026-06-03T06:30:10Z"), + sportType: "running", + source: "APPLE_HEALTH", + avgHeartRate: null, + }, + { + id: "apple-far", + startedAt: new Date("2026-06-03T06:33:30Z"), + sportType: "running", + source: "APPLE_HEALTH", + avgHeartRate: null, + }, + { + id: "whoop", + startedAt: new Date("2026-06-03T06:30:00Z"), + sportType: "running", + source: "WHOOP", + avgHeartRate: 150, + }, + ]); + + expect( + result.filter((row) => row.avgHeartRate === 150).map((row) => row.id), + ).toEqual(["apple-near"]); + }); + it("keeps the WHOOP run when no richer source logged the same session", () => { const rows: RowFixture[] = [ { diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index 65e5a16e2..7f410a6ad 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -44,9 +44,13 @@ import { * other column they fetched alongside. */ export interface WorkoutPickerRow { + userId?: string; startedAt: Date; sportType: string; source: MeasurementSource; + avgHeartRate?: number | null; + maxHeartRate?: number | null; + metadata?: unknown; } /** @@ -64,7 +68,98 @@ const BUCKET_SLOT_MS = 5 * 60 * 1000; function bucketKey(row: WorkoutPickerRow): string { const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS); - return `${slot}:${row.sportType}`; + return `${row.userId ?? ""}:${slot}:${row.sportType}`; +} + +function jsonRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function enrichFromWhoopTwin( + winner: T, + donor: T, +): T { + const winnerMetadata = jsonRecord(winner.metadata); + const donorMetadata = jsonRecord(donor.metadata); + const donorZones = donorMetadata?.zoneDurations; + const needsZones = + donorZones != null && winnerMetadata?.zoneDurations == null; + const needsAvg = + winner.avgHeartRate == null && donor.avgHeartRate != null; + const needsMax = + winner.maxHeartRate == null && donor.maxHeartRate != null; + + if (!needsAvg && !needsMax && !needsZones) return winner; + + return { + ...winner, + ...(needsAvg ? { avgHeartRate: donor.avgHeartRate } : {}), + ...(needsMax ? { maxHeartRate: donor.maxHeartRate } : {}), + ...(needsZones + ? { + metadata: { + ...(winnerMetadata ?? {}), + zoneDurations: donorZones, + }, + } + : {}), + }; +} + +/** + * Pair winners and WHOOP donors one-to-one by nearest start time. A single + * donor can never enrich two same-source occurrences that happen to share a + * fixed slot. Stable input indexes break equal-distance ties. + */ +function enrichWinnersFromWhoop( + winners: readonly T[], + donors: readonly T[], +): T[] { + if (winners.length === 0 || donors.length === 0) return [...winners]; + + const candidates: Array<{ + winnerIndex: number; + donorIndex: number; + deltaMs: number; + }> = []; + winners.forEach((winner, winnerIndex) => { + donors.forEach((donor, donorIndex) => { + candidates.push({ + winnerIndex, + donorIndex, + deltaMs: Math.abs( + winner.startedAt.getTime() - donor.startedAt.getTime(), + ), + }); + }); + }); + candidates.sort( + (left, right) => + left.deltaMs - right.deltaMs || + left.winnerIndex - right.winnerIndex || + left.donorIndex - right.donorIndex, + ); + + const enriched = [...winners]; + const matchedWinners = new Set(); + const matchedDonors = new Set(); + for (const candidate of candidates) { + if ( + matchedWinners.has(candidate.winnerIndex) || + matchedDonors.has(candidate.donorIndex) + ) { + continue; + } + enriched[candidate.winnerIndex] = enrichFromWhoopTwin( + winners[candidate.winnerIndex], + donors[candidate.donorIndex], + ); + matchedWinners.add(candidate.winnerIndex); + matchedDonors.add(candidate.donorIndex); + } + return enriched; } /** @@ -134,9 +229,12 @@ export function pickCanonicalWorkoutRows( continue; } - for (const row of slot.rows) { - if (row.source === picked) canonical.push(row); - } + const winners = slot.rows.filter((row) => row.source === picked); + const whoopDonors = + picked === "WHOOP" + ? [] + : slot.rows.filter((row) => row.source === "WHOOP"); + canonical.push(...enrichWinnersFromWhoop(winners, whoopDonors)); } // Re-sort to preserve input order — the bucket map iterates in