fix(workouts): collapse repeated same-source sessions

This commit is contained in:
Marc-André Bombeck committed 2026-07-29 21:30:52 +02:00
1 parent 90b1a4eaee
commit 0c7b958b1b
2 files changed
+69 -1

No files matched your search

@@ -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<RowFixture>([
{ ...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<RowFixture>([
{
@@ -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<T extends WorkoutPickerRow>(rows: T[]): T[] {
const bySession = new Map<string, T>();
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<T extends WorkoutPickerRow>(
continue;
}
const winners = slot.rows.filter((row) => row.source === picked);
const winners = deduplicateWinningRows(
slot.rows.filter((row) => row.source === picked),
);
const whoopDonors =
picked === "WHOOP"
? []