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 131aebb9e..be2f4f13d 100644 --- a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts +++ b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts @@ -6,6 +6,8 @@ interface RowFixture { id: string; userId?: string; startedAt: Date; + endedAt?: Date; + durationSec?: number; sportType: string; source: "APPLE_HEALTH" | "WHOOP" | "WITHINGS" | "MANUAL" | "IMPORT"; avgHeartRate?: number | null; @@ -257,6 +259,24 @@ describe("pickCanonicalWorkoutRows", () => { }); }); + it("collapses repeated same-source sync rows with the same exact session", () => { + const base = { + startedAt: new Date("2026-07-29T12:54:00Z"), + endedAt: new Date("2026-07-29T13:02:00Z"), + durationSec: 480, + sportType: "strength", + source: "APPLE_HEALTH" as const, + }; + const result = pickCanonicalWorkoutRows([ + { ...base, id: "duplicate-a" }, + { ...base, id: "duplicate-b", avgHeartRate: 90 }, + { ...base, id: "duplicate-c" }, + ]); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe("duplicate-b"); + }); + it("does not collapse or enrich rows belonging to different users", () => { const result = pickCanonicalWorkoutRows([ { diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index fd0567330..31fe6dce2 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -46,6 +46,9 @@ import { export interface WorkoutPickerRow { userId?: string; startedAt: Date; + /** Optional exact-session fields used to collapse repeated same-source sync rows. */ + endedAt?: Date; + durationSec?: number; sportType: string; source: MeasurementSource; avgHeartRate?: number | null; @@ -53,6 +56,49 @@ export interface WorkoutPickerRow { metadata?: unknown; } +/** + * HealthKit can resend the same session with a new external UUID. The source + * ladder correctly chooses Apple over WHOOP, but previously kept every Apple + * row in that winning bucket, which made the web list show the same workout + * two or three times. Only collapse rows when the exact session identity is + * available; fixtures and legacy callers without end/duration remain + * governed by the original bucket contract. + */ +function exactSessionKey(row: WorkoutPickerRow): string | null { + if (row.endedAt == null || row.durationSec == null) return null; + return [ + row.sportType, + row.startedAt.getTime(), + row.endedAt.getTime(), + row.durationSec, + ].join(":"); +} + +function rowRichness(row: WorkoutPickerRow): number { + return ( + (row.avgHeartRate != null ? 2 : 0) + + (row.maxHeartRate != null ? 2 : 0) + + (row.metadata != null ? 1 : 0) + ); +} + +function deduplicateWinningRows(rows: T[]): T[] { + const bySession = new Map(); + const passthrough: T[] = []; + for (const row of rows) { + const key = exactSessionKey(row); + if (key == null) { + passthrough.push(row); + continue; + } + const existing = bySession.get(key); + if (!existing || rowRichness(row) > rowRichness(existing)) { + bySession.set(key, row); + } + } + return [...passthrough, ...bySession.values()]; +} + /** * Bucket-slot length. Two workouts whose `startedAt` falls in the * same 5-minute slot AND share the same sportType are treated as the @@ -253,7 +299,9 @@ export function pickCanonicalWorkoutRows( continue; } - const winners = slot.rows.filter((row) => row.source === picked); + const winners = deduplicateWinningRows( + slot.rows.filter((row) => row.source === picked), + ); const whoopDonors = picked === "WHOOP" ? []