mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(workouts): collapse repeated same-source sessions
This commit is contained in:
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"
|
||||
? []
|
||||
|
||||
Reference in new issue
Block a user