mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
test(guards): give the new guards a floor on what they find
The zone-shift and single-Pearson guards now assert that their matchers find the correct shapes before concluding nothing else matches, and the dense-read guard lists the streak's per-day existence probe with the reason it stays bounded.
This commit is contained in:
1 parent
a22bf91311
commit
7535235b82
7 files changed
+25
-1630
No files matched your search
@@ -75,6 +75,14 @@ describe("one Pearson correlation", () => {
|
||||
});
|
||||
|
||||
it("no other file defines a Pearson coefficient or calls the deleted one", () => {
|
||||
// The homes match the definition matcher, so a matcher gone blind fails here.
|
||||
const definers = sourceFiles().filter((file) =>
|
||||
PEARSON_DEFINITION.test(
|
||||
stripComments(readFileSync(join(SRC, file), "utf8")),
|
||||
),
|
||||
);
|
||||
expect(definers.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const offenders = sourceFiles().filter((file) => {
|
||||
const code = stripComments(readFileSync(join(SRC, file), "utf8"));
|
||||
if (DELETED.test(code)) return true;
|
||||
|
||||
@@ -133,6 +133,8 @@ const ALLOWED_SQL: Record<string, string> = {
|
||||
"Selects account ids; `measurements` is read inside `EXISTS`, one row per account at most.",
|
||||
"lib/rollups/measurement-coverage.ts::1":
|
||||
"Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.",
|
||||
"lib/dashboard/summary-reads.ts::1":
|
||||
"Selects from `generate_series` of the streak window's local days; `measurements` is read inside `EXISTS`, one row per day at most.",
|
||||
};
|
||||
|
||||
interface UntakenRead {
|
||||
|
||||
@@ -28,6 +28,9 @@ const SRC = join(process.cwd(), "src");
|
||||
const SINGLE_SHIFT =
|
||||
/"(?:[a-z_]+_at|scheduled_for)"\s*\)?\s*AT\s+TIME\s+ZONE\s+(?:\$\{|\$\d)/i;
|
||||
|
||||
const DOUBLE_SHIFT =
|
||||
/AT\s+TIME\s+ZONE\s+'UTC'\s*\)?\s*AT\s+TIME\s+ZONE\s+(?:\$\{|\$\d)/i;
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
@@ -52,16 +55,17 @@ describe("naive timestamp zone shifts", () => {
|
||||
});
|
||||
|
||||
it("no production read shifts a naive column once", () => {
|
||||
const offenders = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.startsWith("generated/") &&
|
||||
!file.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(file),
|
||||
)
|
||||
.filter((file) =>
|
||||
SINGLE_SHIFT.test(stripComments(readFileSync(join(SRC, file), "utf8"))),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
|
||||
(file) =>
|
||||
!file.startsWith("generated/") &&
|
||||
!file.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(file),
|
||||
);
|
||||
const code = (file: string) =>
|
||||
stripComments(readFileSync(join(SRC, file), "utf8"));
|
||||
// The reads that shift correctly, so a sweep that reads nothing fails.
|
||||
const doubleShifts = files.filter((file) => DOUBLE_SHIFT.test(code(file)));
|
||||
expect(doubleShifts.length).toBeGreaterThanOrEqual(5);
|
||||
expect(files.filter((file) => SINGLE_SHIFT.test(code(file)))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,478 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_WORKOUT_PROXIMITY_MINUTES,
|
||||
DEFAULT_WORKOUT_SOURCE_PRIORITY,
|
||||
pickCanonicalWorkout,
|
||||
type WorkoutPickerRow,
|
||||
} from "../pick-canonical-workout";
|
||||
|
||||
/**
|
||||
* v1.4.25 W16a — `pickCanonicalWorkout` unit tests.
|
||||
*
|
||||
* Three primary fixtures match the W16a brief:
|
||||
* (a) two sources record the SAME workout (within proximity, same
|
||||
* sport) — picker keeps the canonical one.
|
||||
* (b) two sources record DIFFERENT workouts close in time (within
|
||||
* proximity but different sport types) — picker keeps both.
|
||||
* (c) single source — pass-through.
|
||||
*
|
||||
* Edge fixtures verify the proximity-window boundary, the determinism
|
||||
* contract, and the empty-input fast path.
|
||||
*/
|
||||
|
||||
const D = (iso: string): Date => new Date(iso);
|
||||
|
||||
const row = (
|
||||
id: string,
|
||||
source: WorkoutPickerRow["source"],
|
||||
startedAt: string,
|
||||
sportType: WorkoutPickerRow["sportType"] = "running",
|
||||
extra: Partial<WorkoutPickerRow> = {},
|
||||
): WorkoutPickerRow => ({
|
||||
id,
|
||||
source,
|
||||
startedAt: D(startedAt),
|
||||
sportType,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("DEFAULT_WORKOUT_SOURCE_PRIORITY", () => {
|
||||
it("places APPLE_HEALTH ahead of WITHINGS for cumulative workouts", () => {
|
||||
const apple = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("APPLE_HEALTH");
|
||||
const withings = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("WITHINGS");
|
||||
expect(apple).toBeGreaterThanOrEqual(0);
|
||||
expect(withings).toBeGreaterThan(apple);
|
||||
});
|
||||
|
||||
it("places MANUAL ahead of IMPORT so user-typed entries beat bulk loads", () => {
|
||||
const manual = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("MANUAL");
|
||||
const importIdx = DEFAULT_WORKOUT_SOURCE_PRIORITY.indexOf("IMPORT");
|
||||
expect(importIdx).toBeGreaterThan(manual);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — fixture (a) two-source same workout", () => {
|
||||
it("keeps the APPLE_HEALTH row when both sources record the same run", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
// Withings ingested the same workout 60 seconds later (typical
|
||||
// API-pull lag). Same sport, well within the 5-minute window.
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(1);
|
||||
expect(result.canonical[0].id).toBe("apple-1");
|
||||
expect(result.canonical[0].source).toBe("APPLE_HEALTH");
|
||||
expect(result.clusters).toHaveLength(1);
|
||||
expect(result.clusters[0].members).toHaveLength(2);
|
||||
expect(result.clusters[0].pickedSource).toBe("APPLE_HEALTH");
|
||||
});
|
||||
|
||||
it("keeps every workout from the winning source in one cluster", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z"),
|
||||
row("apple-2", "APPLE_HEALTH", "2026-05-14T07:32:00.000Z"),
|
||||
]);
|
||||
|
||||
expect(result.canonical.map((item) => item.id)).toEqual([
|
||||
"apple-1",
|
||||
"apple-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still picks APPLE_HEALTH when the Withings row arrives first", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:30:00.000Z"),
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:31:30.000Z"),
|
||||
]);
|
||||
expect(result.canonical.map((r) => r.id)).toEqual(["apple-1"]);
|
||||
});
|
||||
|
||||
it("honours a per-user override that flips the ladder", () => {
|
||||
const result = pickCanonicalWorkout(
|
||||
[
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z"),
|
||||
],
|
||||
{ sourcePriority: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "IMPORT"] },
|
||||
);
|
||||
expect(result.canonical.map((r) => r.id)).toEqual(["withings-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — fixture (b) two-source different workouts close in time", () => {
|
||||
it("keeps both rows when sport types differ within the proximity window", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
// 07:30 Walking + 07:32 Running — same wall-clock burst (user
|
||||
// tapped one workout, the watch logged a separate one) but
|
||||
// different sport types, so they must stay distinct rows.
|
||||
row("walk-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "walking"),
|
||||
row("run-1", "WITHINGS", "2026-05-14T07:32:00.000Z", "running"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(2);
|
||||
expect(result.canonical.map((r) => r.id).sort()).toEqual([
|
||||
"run-1",
|
||||
"walk-1",
|
||||
]);
|
||||
expect(result.clusters).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps both rows when the same sport occurs outside the proximity window", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("run-am", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
// 2 hours later — well past the 5-minute cluster window. A
|
||||
// morning run and an afternoon run on the same day must NOT
|
||||
// collapse into one canonical row.
|
||||
row("run-pm", "WITHINGS", "2026-05-14T09:30:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(2);
|
||||
expect(result.clusters).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — fixture (c) single source pass-through", () => {
|
||||
it("returns the only row unchanged when no other source contributed", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(1);
|
||||
expect(result.canonical[0].id).toBe("apple-1");
|
||||
expect(result.clusters[0].pickedSource).toBe("APPLE_HEALTH");
|
||||
});
|
||||
|
||||
it("passes through a batch of disjoint single-source workouts", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("run-1", "APPLE_HEALTH", "2026-05-13T07:30:00.000Z"),
|
||||
row("run-2", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("run-3", "APPLE_HEALTH", "2026-05-15T07:30:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical.map((r) => r.id)).toEqual([
|
||||
"run-1",
|
||||
"run-2",
|
||||
"run-3",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty for empty input", () => {
|
||||
const result = pickCanonicalWorkout([]);
|
||||
expect(result.canonical).toHaveLength(0);
|
||||
expect(result.clusters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — proximity-window boundary", () => {
|
||||
it("exposes the default proximity window as 5 minutes", () => {
|
||||
expect(DEFAULT_WORKOUT_PROXIMITY_MINUTES).toBe(5);
|
||||
});
|
||||
|
||||
it("clusters two rows exactly at the proximity boundary", () => {
|
||||
// 5-minute gap — picker accepts the boundary as in-window so a
|
||||
// server's coarse cron tick (Withings hourly pull at HH:00 paired
|
||||
// with HKWorkout export at HH:05) still dedups cleanly.
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:35:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("anchors a cluster to its first row instead of chaining neighbours", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("anchor", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("neighbour", "WITHINGS", "2026-05-14T07:34:00.000Z"),
|
||||
row("chained", "WITHINGS", "2026-05-14T07:38:00.000Z"),
|
||||
]);
|
||||
|
||||
expect(result.clusters).toHaveLength(2);
|
||||
expect(result.canonical.map((item) => item.id)).toEqual([
|
||||
"anchor",
|
||||
"chained",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps two rows just outside the proximity window", () => {
|
||||
// 5 minutes and 1 second — picker rejects, two clusters.
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:35:01.000Z"),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("respects a custom proximity window", () => {
|
||||
// Same fixture as the same-workout case but with a 30-second
|
||||
// window — the 60-second-later Withings row falls outside and
|
||||
// both rows survive.
|
||||
const result = pickCanonicalWorkout(
|
||||
[
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z"),
|
||||
],
|
||||
{ proximityMinutes: 0.5 },
|
||||
);
|
||||
expect(result.canonical).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — determinism", () => {
|
||||
it("returns the same canonical list regardless of input order", () => {
|
||||
const a = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z"),
|
||||
row("manual-1", "MANUAL", "2026-05-14T07:30:30.000Z"),
|
||||
]);
|
||||
const b = pickCanonicalWorkout([
|
||||
row("manual-1", "MANUAL", "2026-05-14T07:30:30.000Z"),
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z"),
|
||||
]);
|
||||
expect(a.canonical.map((r) => r.id)).toEqual(b.canonical.map((r) => r.id));
|
||||
});
|
||||
|
||||
it("preserves same-source workouts at the same timestamp", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("a-2", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
row("a-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z"),
|
||||
]);
|
||||
|
||||
expect(result.canonical.map((item) => item.id)).toEqual(["a-1", "a-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — field-merge (v1.29.x, WHOOP HR-loss fix)", () => {
|
||||
it("backfills avgHeartRate/maxHeartRate from a lower-priority twin when the winner's is null", () => {
|
||||
// Regression fixture from the brief: a WHOOP cycling row carries live
|
||||
// HR; a higher-priority Apple Health twin for the SAME ride has no HR
|
||||
// (e.g. a manually-logged / GPS-only Apple entry). Before the merge,
|
||||
// the picker kept the Apple row whole and threw the WHOOP HR away.
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling", {
|
||||
avgHeartRate: null,
|
||||
maxHeartRate: null,
|
||||
}),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling", {
|
||||
avgHeartRate: 142,
|
||||
maxHeartRate: 168,
|
||||
}),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(1);
|
||||
const winner = result.canonical[0];
|
||||
expect(winner.id).toBe("apple-1"); // ladder winner stays the base row
|
||||
expect(winner.avgHeartRate).toBe(142);
|
||||
expect(winner.maxHeartRate).toBe(168);
|
||||
expect(result.clusters[0].picked.avgHeartRate).toBe(142);
|
||||
});
|
||||
|
||||
it("backfills a lower-priority donor only onto its nearest winning-source workout", () => {
|
||||
const fixtures = [
|
||||
row(
|
||||
"apple-earlier",
|
||||
"APPLE_HEALTH",
|
||||
"2026-05-14T07:30:00.000Z",
|
||||
"running",
|
||||
{
|
||||
avgHeartRate: null,
|
||||
},
|
||||
),
|
||||
row(
|
||||
"apple-later",
|
||||
"APPLE_HEALTH",
|
||||
"2026-05-14T07:34:00.000Z",
|
||||
"running",
|
||||
{
|
||||
avgHeartRate: null,
|
||||
},
|
||||
),
|
||||
row("whoop-later", "WHOOP", "2026-05-14T07:34:00.000Z", "running", {
|
||||
avgHeartRate: 151,
|
||||
}),
|
||||
];
|
||||
|
||||
for (const input of [fixtures, [...fixtures].reverse()]) {
|
||||
const result = pickCanonicalWorkout(input);
|
||||
|
||||
expect(
|
||||
result.canonical.map(({ id, source, avgHeartRate }) => ({
|
||||
id,
|
||||
source,
|
||||
avgHeartRate,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
id: "apple-earlier",
|
||||
source: "APPLE_HEALTH",
|
||||
avgHeartRate: null,
|
||||
},
|
||||
{
|
||||
id: "apple-later",
|
||||
source: "APPLE_HEALTH",
|
||||
avgHeartRate: 151,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("never overwrites a field the base row already has", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling", {
|
||||
avgHeartRate: 120,
|
||||
}),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling", {
|
||||
avgHeartRate: 142,
|
||||
}),
|
||||
]);
|
||||
expect(result.canonical[0].avgHeartRate).toBe(120);
|
||||
});
|
||||
|
||||
it("backfills totalEnergyKcal, totalDistanceM, and elevationM independently", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling", {
|
||||
totalEnergyKcal: null,
|
||||
totalDistanceM: 15000,
|
||||
elevationM: null,
|
||||
}),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling", {
|
||||
totalEnergyKcal: 480,
|
||||
totalDistanceM: 14800,
|
||||
elevationM: 220,
|
||||
}),
|
||||
]);
|
||||
const winner = result.canonical[0];
|
||||
expect(winner.totalEnergyKcal).toBe(480); // backfilled
|
||||
expect(winner.totalDistanceM).toBe(15000); // base row's own value kept
|
||||
expect(winner.elevationM).toBe(220); // backfilled
|
||||
});
|
||||
|
||||
it("prefers the ladder-highest member that HAS a field over a lower one, when the base row lacks it", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling", {
|
||||
avgHeartRate: null,
|
||||
}),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:30:30.000Z", "cycling", {
|
||||
avgHeartRate: 142,
|
||||
}),
|
||||
row("withings-1", "WITHINGS", "2026-05-14T07:31:00.000Z", "cycling", {
|
||||
avgHeartRate: 99,
|
||||
}),
|
||||
]);
|
||||
// WHOOP outranks WITHINGS on the default ladder — its HR wins the
|
||||
// backfill even though WITHINGS also had one.
|
||||
expect(result.canonical[0].avgHeartRate).toBe(142);
|
||||
});
|
||||
|
||||
it("adopts a specific sport type from a member when the base row's own sportType is generic", () => {
|
||||
// The confirmed bug's downstream symptom: before the WHOOP sport-map
|
||||
// fix, a WHOOP cycling workout could carry sportType "other" (or a
|
||||
// non-canonical raw label) while a lower-priority twin correctly
|
||||
// tagged "cycling". The merge adopts the specific sport so the
|
||||
// cluster still surfaces as a bike ride.
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "other"),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling"),
|
||||
]);
|
||||
expect(result.canonical[0].sportType).toBe("cycling");
|
||||
});
|
||||
|
||||
it("keeps the base row's own specific sportType even when a member disagrees", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling"),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling"),
|
||||
]);
|
||||
expect(result.canonical[0].sportType).toBe("cycling");
|
||||
});
|
||||
|
||||
it("does not run the merge on a single-element cluster", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "cycling", {
|
||||
avgHeartRate: null,
|
||||
}),
|
||||
]);
|
||||
expect(result.canonical[0].avgHeartRate).toBeNull();
|
||||
});
|
||||
|
||||
it("end-to-end: a WHOOP-cycling+HR row clustered with a generic no-HR twin yields a cycling canonical row WITH the HR", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("apple-1", "APPLE_HEALTH", "2026-05-14T07:30:00.000Z", "other", {
|
||||
avgHeartRate: null,
|
||||
totalEnergyKcal: null,
|
||||
}),
|
||||
row("whoop-1", "WHOOP", "2026-05-14T07:31:00.000Z", "cycling", {
|
||||
avgHeartRate: 145,
|
||||
totalEnergyKcal: 512,
|
||||
}),
|
||||
]);
|
||||
expect(result.canonical).toHaveLength(1);
|
||||
const winner = result.canonical[0];
|
||||
expect(winner.source).toBe("APPLE_HEALTH"); // ladder still picks Apple as the base
|
||||
expect(winner.sportType).toBe("cycling"); // adopted from WHOOP
|
||||
expect(winner.avgHeartRate).toBe(145); // backfilled from WHOOP
|
||||
expect(winner.totalEnergyKcal).toBe(512); // backfilled from WHOOP
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickCanonicalWorkout — Strava brick-workout regression", () => {
|
||||
// Legacy Strava labels ("Ride", "Run") are both generic to this picker,
|
||||
// so they share a cluster. The source is identical, however, which means
|
||||
// both legs are distinct records and must survive.
|
||||
it("preserves two same-source raw Strava workouts in one cluster", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("strava-ride", "STRAVA", "2026-06-01T15:00:00.000Z", "Ride"),
|
||||
row("strava-run", "STRAVA", "2026-06-01T15:03:00.000Z", "Run"),
|
||||
]);
|
||||
|
||||
expect(result.clusters).toHaveLength(1);
|
||||
expect(result.canonical.map((item) => item.id)).toEqual([
|
||||
"strava-ride",
|
||||
"strava-run",
|
||||
]);
|
||||
});
|
||||
|
||||
it("post-fix: a canonical Strava Ride + Run pair survives as two distinct rows", () => {
|
||||
// Same timing as the pre-fix fixture above, but with the sportType
|
||||
// values `mapActivity()` now writes (via `mapStravaSportType()`):
|
||||
// "Ride" → "cycling", "Run" → "running". Both are SPECIFIC canonical
|
||||
// buckets and differ from each other, so `isSpecificSportType()` no
|
||||
// longer treats them as sport-compatible and the brick session
|
||||
// survives as two workouts.
|
||||
const result = pickCanonicalWorkout([
|
||||
row("strava-ride", "STRAVA", "2026-06-01T15:00:00.000Z", "cycling"),
|
||||
row("strava-run", "STRAVA", "2026-06-01T15:03:00.000Z", "running"),
|
||||
]);
|
||||
expect(result.clusters).toHaveLength(2);
|
||||
expect(result.canonical).toHaveLength(2);
|
||||
const sports = result.canonical.map((w) => w.sportType).sort();
|
||||
expect(sports).toEqual(["cycling", "running"]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #1054 — workouts a `workouts:write` bridge pushes in carry `EXTERNAL`, which
|
||||
* is on no default ladder. Pinned because the settings copy promises what
|
||||
* follows from that: a bridged session the phone also recorded shows once, as
|
||||
* the phone's copy, and a bridged session nothing else recorded still shows.
|
||||
*/
|
||||
describe("pickCanonicalWorkout — the unranked EXTERNAL source", () => {
|
||||
it("is on no default ladder", () => {
|
||||
expect(DEFAULT_WORKOUT_SOURCE_PRIORITY).not.toContain("EXTERNAL");
|
||||
});
|
||||
|
||||
it("yields to the phone's copy of the same session", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("bridge-1", "EXTERNAL", "2026-09-20T06:30:00.000Z"),
|
||||
row("apple-1", "APPLE_HEALTH", "2026-09-20T06:31:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical.map((w) => w.id)).toEqual(["apple-1"]);
|
||||
});
|
||||
|
||||
it("survives when nothing else recorded the session", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("bridge-1", "EXTERNAL", "2026-09-20T06:30:00.000Z"),
|
||||
row("apple-1", "APPLE_HEALTH", "2026-09-20T09:00:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical.map((w) => w.id).sort()).toEqual([
|
||||
"apple-1",
|
||||
"bridge-1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,437 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W16a — cross-source workout canonical picker.
|
||||
*
|
||||
* Companion to `pickCanonicalSourceRows()` (the Measurement-row picker
|
||||
* in `src/lib/analytics/source-priority.ts`). Workouts have a similar
|
||||
* double-ingest problem coming in v1.5: the iOS app will write an
|
||||
* HKWorkout row at the same wall-clock time the Withings Activity API
|
||||
* already supplied — same run, two rows.
|
||||
*
|
||||
* This helper is scaffolding only — it returns the canonical workout
|
||||
* list, doesn't mutate the DB, and is not yet wired into any query
|
||||
* path. The wiring lands alongside the workout ingest endpoint
|
||||
* (W16b in the v1.4.25 plan) or with the v1.5 iOS rollout, whichever
|
||||
* arrives first.
|
||||
*
|
||||
* Algorithm
|
||||
* ─────────
|
||||
*
|
||||
* 1. Cluster workouts whose start timestamps land within
|
||||
* `proximityMinutes` of each other (default 5 min — Apple's
|
||||
* ScanWatch passthrough lag plus a small clock-skew buffer).
|
||||
* Sport type must match across the cluster; a "Walking" workout
|
||||
* and a "Running" workout starting at the same instant stay
|
||||
* distinct rows so the cardio-vs-strength split stays clean. Once
|
||||
* an integration writes a canonical `sportType` (every source does,
|
||||
* as of the WHOOP fix below), a cross-source pair of the SAME
|
||||
* workout always shares a sport bucket and clusters correctly —
|
||||
* before that fix a WHOOP "whoop_sport_1" row could never cluster
|
||||
* with an Apple Health "cycling" row.
|
||||
* 2. Per cluster, walk the per-user source-priority ladder for
|
||||
* workouts (default `APPLE_HEALTH > WITHINGS > MANUAL > IMPORT`)
|
||||
* and keep the first source that has a row in the cluster as the
|
||||
* BASE of the canonical row. All other rows from the same cluster
|
||||
* are dropped from the canonical list.
|
||||
* 3. Field-merge (multi-element clusters only): each workout from the
|
||||
* winning source is backfilled only from losing-source members matched
|
||||
* to that workout by sport identity and start-time proximity. A donor is
|
||||
* assigned to one deterministic best winner (exact sport before generic
|
||||
* compatibility, then nearest timestamp). Missing `avgHeartRate`,
|
||||
* `maxHeartRate`, `totalEnergyKcal`, `totalDistanceM`, and `elevationM`
|
||||
* fields come from the ladder-highest matched donor that has the field.
|
||||
* The same step adopts a specific sport type when the base row is generic
|
||||
* (`"other"` / unrecognised). Every OTHER field on the base row (id,
|
||||
* source, startedAt, route, metadata, ...) is untouched — the merge only
|
||||
* ever fills a gap, never overwrites a value the base row already has.
|
||||
* 4. Single-element clusters (no near-duplicate) pass through
|
||||
* unchanged regardless of source — no merge step runs.
|
||||
*
|
||||
* Rationale for the default ladder
|
||||
* ────────────────────────────────
|
||||
*
|
||||
* - `APPLE_HEALTH` first: the iOS-app passthrough carries the
|
||||
* watch's per-second sample stream (energy, HR, optional GPS),
|
||||
* so when both sources have the same workout it's the richer
|
||||
* row.
|
||||
* - `WITHINGS` second: ScanWatch syncs into Withings cloud too,
|
||||
* but the API caps each workout at coarser aggregates (mean HR,
|
||||
* totals, no per-sample HR series). Loses cleanly when paired
|
||||
* with the HKWorkout twin.
|
||||
* - `MANUAL` third: the user typed it; trust beats anything from
|
||||
* `IMPORT` (CSV / XML) but loses to a sensor stream.
|
||||
* - `IMPORT` last: bulk historical imports (Apple Health XML,
|
||||
* Strava export, Garmin TCX) — useful when no live sensor source
|
||||
* covers the period but the lowest fidelity when one does.
|
||||
*
|
||||
* The picker is deterministic on `(rows, ladder, proximityMinutes)`
|
||||
* and stable on input order. The caller is responsible for sorting
|
||||
* rows by `startedAt ASC, id ASC` before invocation so the picker's
|
||||
* cluster-building walks a fixed sequence.
|
||||
*/
|
||||
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
|
||||
import {
|
||||
workoutSportTypeEnum,
|
||||
type WorkoutSportType,
|
||||
} from "@/lib/validations/workout";
|
||||
|
||||
/**
|
||||
* Minimum row shape the helper consults. Exposed as an interface so the
|
||||
* Prisma `Workout` model and the not-yet-existing ingest DTO both
|
||||
* compose with the picker without a transform step. Anything else on
|
||||
* the row (route blob, metadata, ...) flows through untouched — ONLY the
|
||||
* fields declared below are eligible for the step-3 field-merge, and even
|
||||
* those are backfilled (fills a gap on the base row), never overwritten.
|
||||
*/
|
||||
export interface WorkoutPickerRow {
|
||||
/** Stable identifier — `id` for DB rows, externalId+source for
|
||||
* not-yet-persisted ingest payloads. Used for deterministic
|
||||
* tie-breaking when two sources both win the priority round. */
|
||||
id: string;
|
||||
startedAt: Date;
|
||||
source: MeasurementSource;
|
||||
/** Canonical sport string. Workouts of different sport types never
|
||||
* collapse into one cluster — see step 1 of the algorithm. May be
|
||||
* adopted from a losing cluster member by the step-3 merge when the
|
||||
* base row's own value is generic — see `mergeClusterFields()`. */
|
||||
sportType: WorkoutSportType | string;
|
||||
/** Backfillable richer fields — present when the source carries live
|
||||
* sensor data (HR strap, GPS), absent/null on a coarser summary from
|
||||
* another source recording the same workout. */
|
||||
avgHeartRate?: number | null;
|
||||
maxHeartRate?: number | null;
|
||||
totalEnergyKcal?: number | null;
|
||||
totalDistanceM?: number | null;
|
||||
elevationM?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The numeric fields step 3 of the algorithm may backfill onto the base
|
||||
* row. Listed explicitly (not derived via `keyof`) so each field's copy
|
||||
* stays independently type-checked in `mergeClusterFields()`.
|
||||
*/
|
||||
const MERGEABLE_NUMERIC_FIELDS = [
|
||||
"avgHeartRate",
|
||||
"maxHeartRate",
|
||||
"totalEnergyKcal",
|
||||
"totalDistanceM",
|
||||
"elevationM",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `true` for a specific canonical sport bucket; `false` for the generic
|
||||
* `"other"` bucket or a string outside `workoutSportTypeEnum` entirely (a
|
||||
* not-yet-normalised integration's raw label). Drives the sport-type
|
||||
* adoption half of the merge.
|
||||
*/
|
||||
function isSpecificSportType(value: string): boolean {
|
||||
return (
|
||||
value !== "other" &&
|
||||
(workoutSportTypeEnum.options as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
function areSportTypesCompatible(left: string, right: string): boolean {
|
||||
return (
|
||||
left === right || !isSpecificSportType(left) || !isSpecificSportType(right)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match one losing-source donor to one winning-source workout. Exact sport
|
||||
* identity is stronger than generic compatibility; within the same identity
|
||||
* strength, the nearest start wins. `winners` is already startedAt/id-sorted,
|
||||
* so an exact tie resolves deterministically to the first winner.
|
||||
*/
|
||||
function findMatchingWinner<T extends WorkoutPickerRow>(
|
||||
donor: T,
|
||||
winners: readonly T[],
|
||||
proximityMs: number,
|
||||
): T | undefined {
|
||||
let best: T | undefined;
|
||||
let bestHasExactSport = false;
|
||||
let bestDeltaMs = Infinity;
|
||||
|
||||
for (const winner of winners) {
|
||||
if (!areSportTypesCompatible(winner.sportType, donor.sportType)) continue;
|
||||
|
||||
const deltaMs = Math.abs(
|
||||
winner.startedAt.getTime() - donor.startedAt.getTime(),
|
||||
);
|
||||
if (deltaMs > proximityMs) continue;
|
||||
|
||||
const hasExactSport = winner.sportType === donor.sportType;
|
||||
if (
|
||||
!best ||
|
||||
(hasExactSport && !bestHasExactSport) ||
|
||||
(hasExactSport === bestHasExactSport && deltaMs < bestDeltaMs)
|
||||
) {
|
||||
best = winner;
|
||||
bestHasExactSport = hasExactSport;
|
||||
bestDeltaMs = deltaMs;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Ladder position of `source`, or `ladder.length` (last) when the source
|
||||
* isn't on the ladder at all — an unranked source only wins a merge field
|
||||
* when no ranked member has it. */
|
||||
function ladderRank(
|
||||
source: MeasurementSource,
|
||||
ladder: readonly MeasurementSource[],
|
||||
): number {
|
||||
const idx = ladder.indexOf(source);
|
||||
return idx === -1 ? ladder.length : idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of the algorithm: backfill the ladder-winning base row's missing
|
||||
* richer fields, and its generic sport type, from other cluster members.
|
||||
*
|
||||
* Deterministic: for each candidate field, the ladder-highest member that
|
||||
* HAS a non-null value wins that field. `members` is already
|
||||
* startedAt/id-sorted by the caller, so a same-rank tie resolves to
|
||||
* whichever member sorts first.
|
||||
*
|
||||
* A single-element cluster returns `winning` unchanged — nothing to merge.
|
||||
*/
|
||||
function mergeClusterFields<T extends WorkoutPickerRow>(
|
||||
winning: T,
|
||||
members: readonly T[],
|
||||
ladder: readonly MeasurementSource[],
|
||||
): T {
|
||||
if (members.length <= 1) return winning;
|
||||
|
||||
const merged: T = { ...winning };
|
||||
|
||||
for (const field of MERGEABLE_NUMERIC_FIELDS) {
|
||||
if (merged[field] != null) continue;
|
||||
let best: T | undefined;
|
||||
let bestRank = Infinity;
|
||||
for (const member of members) {
|
||||
const value = member[field];
|
||||
if (value == null) continue;
|
||||
const rank = ladderRank(member.source, ladder);
|
||||
if (rank < bestRank) {
|
||||
bestRank = rank;
|
||||
best = member;
|
||||
}
|
||||
}
|
||||
if (best) merged[field] = best[field];
|
||||
}
|
||||
|
||||
if (!isSpecificSportType(merged.sportType)) {
|
||||
let best: T | undefined;
|
||||
let bestRank = Infinity;
|
||||
for (const member of members) {
|
||||
if (!isSpecificSportType(member.sportType)) continue;
|
||||
const rank = ladderRank(member.source, ladder);
|
||||
if (rank < bestRank) {
|
||||
bestRank = rank;
|
||||
best = member;
|
||||
}
|
||||
}
|
||||
if (best) merged.sportType = best.sportType;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user, per-source priority list for workouts. The shape is
|
||||
* parallel to `SOURCE_PRIORITY_METRIC_KEYS` entries but stored
|
||||
* separately because workouts are their own entity (not a Measurement
|
||||
* row), and the metric-priority Json blob on `User.sourcePriorityJson`
|
||||
* never carries a `workouts` key today.
|
||||
*
|
||||
* Persistence of the per-user value lands when the dedup wiring lands;
|
||||
* the helper accepts an explicit ladder so the future caller can pass
|
||||
* the merged value without dragging schema work into this file.
|
||||
*/
|
||||
export const DEFAULT_WORKOUT_SOURCE_PRIORITY: readonly MeasurementSource[] = [
|
||||
"APPLE_HEALTH",
|
||||
// v1.11.0 — WHOOP ranks second. Apple Watch GPS + HR is the richest run
|
||||
// record; a WHOOP strap is the next-best when no watch logged the same
|
||||
// session. The read-time picker clusters a WHOOP run and an Apple-Health
|
||||
// run by (activityType, startedAt ± window) and keeps the ladder winner.
|
||||
"WHOOP",
|
||||
// v1.12.0 — Fitbit/Pixel exercise logs rank just below WHOOP: a wrist
|
||||
// wearable in the same class, the next-best when no watch logged the same
|
||||
// session.
|
||||
"FITBIT",
|
||||
"WITHINGS",
|
||||
// v1.32.15 — Polar ranks below Withings and above Strava: a Polar watch is a
|
||||
// device-native HR capture (unlike a Strava re-upload with degraded/absent
|
||||
// HR), so a Polar exercise outranks a Strava twin of the same session but
|
||||
// sits below the primary on-wrist wearables. Matches Polar's slot in the
|
||||
// read-time `steps` ladder (`DEFAULT_SOURCE_PRIORITY.steps`).
|
||||
"POLAR",
|
||||
// v1.28.x — Strava ranks below the device-native captures (Apple/WHOOP/
|
||||
// Fitbit/Withings on-wrist HR) but above MANUAL/IMPORT: a Strava activity is
|
||||
// often itself a re-upload of an Apple/Garmin recording, so its HR/calories
|
||||
// are lower-fidelity or absent on the summary. A user who records primarily
|
||||
// on Strava (phone GPS) can promote it in Settings → Sources.
|
||||
"STRAVA",
|
||||
"MANUAL",
|
||||
"IMPORT",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Default proximity window. Apple's HealthKit passthrough is observed
|
||||
* to arrive 0–60 seconds after the watch closes the workout; Withings
|
||||
* Activity API rounds to the minute. 5 minutes covers both lags plus a
|
||||
* small clock-skew buffer between the iOS app's clock and the server.
|
||||
*/
|
||||
export const DEFAULT_WORKOUT_PROXIMITY_MINUTES = 5;
|
||||
|
||||
export interface PickCanonicalWorkoutOptions {
|
||||
/**
|
||||
* Per-user source ladder. Defaults to
|
||||
* `DEFAULT_WORKOUT_SOURCE_PRIORITY`. The picker walks left-to-right
|
||||
* and keeps the first source present in each cluster.
|
||||
*/
|
||||
sourcePriority?: readonly MeasurementSource[];
|
||||
/**
|
||||
* Cluster window. Defaults to `DEFAULT_WORKOUT_PROXIMITY_MINUTES`.
|
||||
* Workouts whose start timestamps are within this many minutes of
|
||||
* the cluster anchor join that cluster; later neighbours cannot extend
|
||||
* the window.
|
||||
*/
|
||||
proximityMinutes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the canonical-source workout list. See file-level docs for the
|
||||
* algorithm; returns the surviving input rows plus one representative
|
||||
* record per overlap cluster for debug overlays and audit logging.
|
||||
*/
|
||||
export function pickCanonicalWorkout<T extends WorkoutPickerRow>(
|
||||
rows: readonly T[],
|
||||
options: PickCanonicalWorkoutOptions = {},
|
||||
): {
|
||||
canonical: T[];
|
||||
clusters: Array<{
|
||||
/** Every row that clustered together, unmerged. */
|
||||
members: T[];
|
||||
pickedSource: MeasurementSource;
|
||||
/** Representative winning row after cross-source field merge. */
|
||||
picked: T;
|
||||
}>;
|
||||
} {
|
||||
if (rows.length === 0) {
|
||||
return { canonical: [], clusters: [] };
|
||||
}
|
||||
|
||||
const ladder = options.sourcePriority ?? DEFAULT_WORKOUT_SOURCE_PRIORITY;
|
||||
const proximityMinutes =
|
||||
options.proximityMinutes ?? DEFAULT_WORKOUT_PROXIMITY_MINUTES;
|
||||
const proximityMs = proximityMinutes * 60_000;
|
||||
|
||||
// Sort by startedAt so cluster building is a single linear pass.
|
||||
// Stable secondary sort on `id` keeps determinism on ties — two rows
|
||||
// recorded at the exact same instant with the same id ordering give
|
||||
// the same picker output on every call.
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const dt = a.startedAt.getTime() - b.startedAt.getTime();
|
||||
if (dt !== 0) return dt;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
// Build clusters keyed by sport type (a Walking + Running pair
|
||||
// starting at the same instant must stay distinct — different
|
||||
// workouts entirely) — WITH one relaxation: a GENERIC sport
|
||||
// ("other" / an unrecognised raw string) is treated as compatible
|
||||
// with any SPECIFIC sport, so a thinly-classified row from one
|
||||
// source (an unmapped import, a not-yet-normalised integration) can
|
||||
// still cluster with a specific-sport twin from another source
|
||||
// recording the SAME real-world workout — the merge step below then
|
||||
// adopts the specific sport onto the canonical row. Two DIFFERENT
|
||||
// specific sports (Walking vs Running) never become compatible this
|
||||
// way — the core invariant is unchanged.
|
||||
const clusters: T[][] = [];
|
||||
for (const row of sorted) {
|
||||
// Compare against the first row in the cluster. A later neighbour
|
||||
// inside the window must not extend the cluster beyond its anchor.
|
||||
let placed = false;
|
||||
for (let i = clusters.length - 1; i >= 0; i--) {
|
||||
const cluster = clusters[i];
|
||||
const head = cluster[0];
|
||||
const sameSport = head.sportType === row.sportType;
|
||||
if (!areSportTypesCompatible(head.sportType, row.sportType)) continue;
|
||||
if (row.startedAt.getTime() - head.startedAt.getTime() <= proximityMs) {
|
||||
cluster.push(row);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
if (sameSport) break;
|
||||
}
|
||||
if (!placed) {
|
||||
clusters.push([row]);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve single-source clusters. For a real cross-source overlap,
|
||||
// select the winning source and retain every workout it contributed.
|
||||
const canonical: T[] = [];
|
||||
const clusterRecords: Array<{
|
||||
members: T[];
|
||||
pickedSource: MeasurementSource;
|
||||
picked: T;
|
||||
}> = [];
|
||||
for (const cluster of clusters) {
|
||||
const sources = new Set(cluster.map((row) => row.source));
|
||||
if (sources.size === 1) {
|
||||
canonical.push(...cluster);
|
||||
clusterRecords.push({
|
||||
members: cluster,
|
||||
pickedSource: cluster[0].source,
|
||||
picked: cluster[0],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let pickedSource: MeasurementSource | undefined;
|
||||
for (const candidate of ladder) {
|
||||
if (cluster.some((row) => row.source === candidate)) {
|
||||
pickedSource = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!pickedSource) {
|
||||
canonical.push(...cluster);
|
||||
clusterRecords.push({
|
||||
members: cluster,
|
||||
pickedSource: cluster[0].source,
|
||||
picked: cluster[0],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const winningRows = cluster.filter((row) => row.source === pickedSource);
|
||||
const membersByWinner = new Map<T, T[]>(
|
||||
winningRows.map((winner) => [winner, [winner]]),
|
||||
);
|
||||
for (const donor of cluster) {
|
||||
if (donor.source === pickedSource) continue;
|
||||
const matchedWinner = findMatchingWinner(donor, winningRows, proximityMs);
|
||||
if (matchedWinner) membersByWinner.get(matchedWinner)?.push(donor);
|
||||
}
|
||||
const winners = winningRows.map((winner) =>
|
||||
mergeClusterFields(
|
||||
winner,
|
||||
membersByWinner.get(winner) ?? [winner],
|
||||
ladder,
|
||||
),
|
||||
);
|
||||
canonical.push(...winners);
|
||||
clusterRecords.push({
|
||||
members: cluster,
|
||||
pickedSource,
|
||||
picked: winners[0],
|
||||
});
|
||||
}
|
||||
|
||||
return { canonical, clusters: clusterRecords };
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
/**
|
||||
* v1.4.42 W5 — write-time canonical-row picker tests.
|
||||
*
|
||||
* Pairs with `src/lib/workouts/canonical-rows.ts`. The helper is the
|
||||
* pre-`createMany` dedup pass invoked by `POST /api/workouts/batch`
|
||||
* when the v1.5 iOS sprint posts a batch carrying both an Apple Watch
|
||||
* row and a Withings ScanWatch row for the same Sunday-morning run.
|
||||
* The tests pin the algorithm contract so a future tightening of the
|
||||
* window or a tie-breaker tweak surfaces a failing assertion rather
|
||||
* than a silent regression in the duplicate-drop count.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
dedupeWorkoutBatch,
|
||||
WORKOUT_DEDUP_WINDOW_MS,
|
||||
type WorkoutRow,
|
||||
} from "../canonical-rows";
|
||||
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
|
||||
interface TestRow extends WorkoutRow {
|
||||
/** Marker so the test can assert which input row survived. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
function row(partial: Partial<TestRow> & { id: string }): TestRow {
|
||||
return {
|
||||
id: partial.id,
|
||||
userId: partial.userId ?? "user-1",
|
||||
activityType: partial.activityType ?? "running",
|
||||
startedAt: partial.startedAt ?? new Date("2026-05-21T08:00:00Z"),
|
||||
source: partial.source ?? "APPLE_HEALTH",
|
||||
caloriesKcal: partial.caloriesKcal ?? null,
|
||||
createdAt: partial.createdAt ?? null,
|
||||
index: partial.index,
|
||||
};
|
||||
}
|
||||
|
||||
describe("dedupeWorkoutBatch", () => {
|
||||
it("returns an empty array on empty input", () => {
|
||||
expect(dedupeWorkoutBatch([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves overlapping workouts from the same source", () => {
|
||||
const a = row({
|
||||
id: "a",
|
||||
source: "APPLE_HEALTH",
|
||||
caloriesKcal: 420,
|
||||
createdAt: new Date("2026-05-21T08:30:00Z"),
|
||||
});
|
||||
const b = row({
|
||||
id: "b",
|
||||
source: "APPLE_HEALTH",
|
||||
caloriesKcal: 420,
|
||||
createdAt: new Date("2026-05-21T08:31:00Z"),
|
||||
});
|
||||
|
||||
expect(dedupeWorkoutBatch([a, b]).map((item) => item.id)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers the higher-priority source on a cross-source overlap inside the window", () => {
|
||||
// Apple Watch + Withings ScanWatch both record the same run. The
|
||||
// window is satisfied (rows are 60 s apart, well under the 90 s
|
||||
// ceiling) and the activityType matches → group dedups to one
|
||||
// survivor. APPLE_HEALTH outranks WITHINGS per the ladder.
|
||||
const apple = row({
|
||||
id: "apple",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
caloriesKcal: 400,
|
||||
});
|
||||
const withings = row({
|
||||
id: "withings",
|
||||
source: "WITHINGS",
|
||||
startedAt: new Date("2026-05-21T08:01:00Z"),
|
||||
caloriesKcal: 500,
|
||||
});
|
||||
|
||||
const out = dedupeWorkoutBatch([apple, withings]);
|
||||
expect(out).toHaveLength(1);
|
||||
// Source-axis wins outright — APPLE_HEALTH beats WITHINGS even
|
||||
// when WITHINGS has higher calories. The calories tie-break only
|
||||
// fires WITHIN a single source tier.
|
||||
expect(out[0]!.id).toBe("apple");
|
||||
});
|
||||
|
||||
it("retains every row from the winning source in a cross-source overlap", () => {
|
||||
const appleFirst = row({
|
||||
id: "apple-first",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
});
|
||||
const withings = row({
|
||||
id: "withings",
|
||||
source: "WITHINGS",
|
||||
startedAt: new Date("2026-05-21T08:00:30Z"),
|
||||
});
|
||||
const appleSecond = row({
|
||||
id: "apple-second",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-21T08:01:00Z"),
|
||||
});
|
||||
|
||||
expect(
|
||||
dedupeWorkoutBatch([appleFirst, withings, appleSecond]).map(
|
||||
(item) => item.id,
|
||||
),
|
||||
).toEqual(["apple-first", "apple-second"]);
|
||||
});
|
||||
|
||||
it("returns every row unchanged when no overlap exists", () => {
|
||||
// Two runs 10 min apart from the SAME source — well outside the
|
||||
// 90 s window. Both must survive; the helper is not a generic
|
||||
// "one run per day" filter.
|
||||
const first = row({
|
||||
id: "first",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
});
|
||||
const second = row({
|
||||
id: "second",
|
||||
startedAt: new Date("2026-05-21T08:10:00Z"),
|
||||
});
|
||||
|
||||
const out = dedupeWorkoutBatch([first, second]);
|
||||
expect(out.map((r) => r.id)).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("returns rows from different users untouched even on identical timestamps", () => {
|
||||
// A multi-user batch (theoretically possible from an admin path)
|
||||
// must never collapse rows across `userId`. The window check
|
||||
// includes the userId in the grouping key.
|
||||
const u1 = row({
|
||||
id: "u1",
|
||||
userId: "user-1",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
});
|
||||
const u2 = row({
|
||||
id: "u2",
|
||||
userId: "user-2",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
});
|
||||
|
||||
const out = dedupeWorkoutBatch([u1, u2]);
|
||||
expect(out.map((r) => r.id).sort()).toEqual(["u1", "u2"]);
|
||||
});
|
||||
|
||||
it("returns rows of different activity types untouched at the same instant", () => {
|
||||
// A user opens a "Walk" workout on the Watch and a "Strength"
|
||||
// workout on the iPhone at the same instant. The window matches
|
||||
// but the activityType doesn't — two distinct rows, both survive.
|
||||
const walk = row({
|
||||
id: "walk",
|
||||
activityType: "walking",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
});
|
||||
const strength = row({
|
||||
id: "strength",
|
||||
activityType: "strength",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
});
|
||||
|
||||
const out = dedupeWorkoutBatch([walk, strength]);
|
||||
expect(out.map((r) => r.id).sort()).toEqual(["strength", "walk"]);
|
||||
});
|
||||
|
||||
it("preserves same-source workouts when calories differ", () => {
|
||||
const lo = row({
|
||||
id: "lo",
|
||||
source: "APPLE_HEALTH",
|
||||
caloriesKcal: 200,
|
||||
});
|
||||
const hi = row({
|
||||
id: "hi",
|
||||
source: "APPLE_HEALTH",
|
||||
caloriesKcal: 500,
|
||||
createdAt: new Date("2026-05-21T09:00:00Z"),
|
||||
});
|
||||
|
||||
expect(dedupeWorkoutBatch([lo, hi]).map((item) => item.id)).toEqual([
|
||||
"lo",
|
||||
"hi",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves same-source input order when creation times differ", () => {
|
||||
const early = row({
|
||||
id: "early",
|
||||
source: "WITHINGS",
|
||||
caloriesKcal: 350,
|
||||
createdAt: new Date("2026-05-21T08:30:00Z"),
|
||||
});
|
||||
const late = row({
|
||||
id: "late",
|
||||
source: "WITHINGS",
|
||||
caloriesKcal: 350,
|
||||
createdAt: new Date("2026-05-21T08:45:00Z"),
|
||||
});
|
||||
|
||||
expect(dedupeWorkoutBatch([late, early]).map((item) => item.id)).toEqual([
|
||||
"late",
|
||||
"early",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves same-source input order when every field ties", () => {
|
||||
const first = row({ id: "first", source: "MANUAL" });
|
||||
const second = row({ id: "second", source: "MANUAL" });
|
||||
|
||||
expect(dedupeWorkoutBatch([first, second]).map((item) => item.id)).toEqual([
|
||||
"first",
|
||||
"second",
|
||||
]);
|
||||
expect(dedupeWorkoutBatch([second, first]).map((item) => item.id)).toEqual([
|
||||
"second",
|
||||
"first",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a row exactly at the ±90 s boundary as in-window", () => {
|
||||
// The window is inclusive (`<=`). A row landing on the boundary
|
||||
// joins the group; one millisecond past stays separate.
|
||||
const anchor = row({
|
||||
id: "anchor",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
});
|
||||
const onBoundary = row({
|
||||
id: "boundary",
|
||||
source: "WITHINGS",
|
||||
startedAt: new Date(anchor.startedAt.getTime() + WORKOUT_DEDUP_WINDOW_MS),
|
||||
});
|
||||
const justOutside = row({
|
||||
id: "outside",
|
||||
source: "WITHINGS",
|
||||
startedAt: new Date(
|
||||
anchor.startedAt.getTime() + WORKOUT_DEDUP_WINDOW_MS + 1,
|
||||
),
|
||||
});
|
||||
|
||||
// anchor + onBoundary → one group; APPLE_HEALTH wins.
|
||||
expect(dedupeWorkoutBatch([anchor, onBoundary]).map((r) => r.id)).toEqual([
|
||||
"anchor",
|
||||
]);
|
||||
|
||||
// anchor + justOutside → two distinct groups, both survive.
|
||||
expect(dedupeWorkoutBatch([anchor, justOutside]).map((r) => r.id)).toEqual([
|
||||
"anchor",
|
||||
"outside",
|
||||
]);
|
||||
});
|
||||
|
||||
it("anchors overlap groups to the first row instead of chaining neighbours", () => {
|
||||
const anchor = row({
|
||||
id: "anchor",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
});
|
||||
const neighbour = row({
|
||||
id: "neighbour",
|
||||
source: "WITHINGS",
|
||||
startedAt: new Date(
|
||||
anchor.startedAt.getTime() + WORKOUT_DEDUP_WINDOW_MS - 1_000,
|
||||
),
|
||||
});
|
||||
const chained = row({
|
||||
id: "chained",
|
||||
source: "WITHINGS",
|
||||
startedAt: new Date(
|
||||
anchor.startedAt.getTime() + 2 * (WORKOUT_DEDUP_WINDOW_MS - 1_000),
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
dedupeWorkoutBatch([anchor, neighbour, chained]).map((item) => item.id),
|
||||
).toEqual(["anchor", "chained"]);
|
||||
});
|
||||
|
||||
it("keeps anchor windows stable when the batch arrives out of order", () => {
|
||||
const anchor = row({
|
||||
id: "anchor",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
});
|
||||
const neighbour = row({
|
||||
id: "neighbour",
|
||||
source: "WITHINGS",
|
||||
startedAt: new Date(
|
||||
anchor.startedAt.getTime() + WORKOUT_DEDUP_WINDOW_MS - 1_000,
|
||||
),
|
||||
});
|
||||
const chained = row({
|
||||
id: "chained",
|
||||
source: "WITHINGS",
|
||||
startedAt: new Date(
|
||||
anchor.startedAt.getTime() + 2 * (WORKOUT_DEDUP_WINDOW_MS - 1_000),
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
dedupeWorkoutBatch([neighbour, chained, anchor]).map((item) => item.id),
|
||||
).toEqual(["chained", "anchor"]);
|
||||
});
|
||||
|
||||
it("walks the full source ladder (APPLE_HEALTH > WITHINGS > MANUAL > IMPORT)", () => {
|
||||
const sources: MeasurementSource[] = [
|
||||
"IMPORT",
|
||||
"MANUAL",
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
];
|
||||
const rows = sources.map((source, idx) =>
|
||||
row({
|
||||
id: source,
|
||||
source,
|
||||
// Stagger by 10 s so each row sits inside the 90 s window
|
||||
// relative to the anchor (the first row inserted).
|
||||
startedAt: new Date(
|
||||
new Date("2026-05-21T08:00:00Z").getTime() + idx * 10_000,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(dedupeWorkoutBatch(rows).map((r) => r.id)).toEqual(["APPLE_HEALTH"]);
|
||||
});
|
||||
|
||||
it("honours a custom user ladder that promotes MANUAL above APPLE_HEALTH", () => {
|
||||
// v1.4.43 W9 — when the user has customised Settings → Sources to
|
||||
// promote MANUAL above APPLE_HEALTH, a Manual row inside the 90 s
|
||||
// window MUST win over the Apple Watch twin. Without the
|
||||
// user-priority wiring this helper would silently drop the
|
||||
// user's preferred row at write-time before the read-time picker
|
||||
// ever saw it.
|
||||
const apple = row({
|
||||
id: "apple",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
caloriesKcal: 500,
|
||||
});
|
||||
const manual = row({
|
||||
id: "manual",
|
||||
source: "MANUAL",
|
||||
startedAt: new Date("2026-05-21T08:01:00Z"),
|
||||
caloriesKcal: 300,
|
||||
});
|
||||
|
||||
const userPriorityJson = {
|
||||
steps: ["MANUAL", "APPLE_HEALTH", "WITHINGS"] as MeasurementSource[],
|
||||
};
|
||||
|
||||
const out = dedupeWorkoutBatch([apple, manual], userPriorityJson);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.id).toBe("manual");
|
||||
});
|
||||
|
||||
it("falls back to the canonical default ladder when userPriorityJson is null", () => {
|
||||
// Null = no user preference persisted. The helper must walk the
|
||||
// canonical `DEFAULT_WORKOUT_SOURCE_PRIORITY` ladder so existing
|
||||
// callers that don't supply the blob keep their v1.4.42 behaviour.
|
||||
const apple = row({
|
||||
id: "apple",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: new Date("2026-05-21T08:00:00Z"),
|
||||
});
|
||||
const manual = row({
|
||||
id: "manual",
|
||||
source: "MANUAL",
|
||||
startedAt: new Date("2026-05-21T08:01:00Z"),
|
||||
});
|
||||
|
||||
expect(dedupeWorkoutBatch([apple, manual], null).map((r) => r.id)).toEqual([
|
||||
"apple",
|
||||
]);
|
||||
expect(
|
||||
dedupeWorkoutBatch([apple, manual], undefined).map((r) => r.id),
|
||||
).toEqual(["apple"]);
|
||||
// Argument omitted entirely — same behaviour as null/undefined.
|
||||
expect(dedupeWorkoutBatch([apple, manual]).map((r) => r.id)).toEqual([
|
||||
"apple",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate caller rows", () => {
|
||||
// The picker is pure — the caller's array reference and row
|
||||
// identity survive the call so a future audit-overlay can
|
||||
// re-inspect the dropped rows.
|
||||
const a = row({ id: "a", source: "APPLE_HEALTH", caloriesKcal: 100 });
|
||||
const b = row({ id: "b", source: "WITHINGS", caloriesKcal: 100 });
|
||||
const before = JSON.stringify([a, b]);
|
||||
dedupeWorkoutBatch([a, b]);
|
||||
expect(JSON.stringify([a, b])).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,308 +0,0 @@
|
||||
/**
|
||||
* v1.4.42 W5 — write-time cross-source canonical-row picker for
|
||||
* incoming `Workout` ingest payloads.
|
||||
*
|
||||
* Why this exists alongside the read-time helper
|
||||
* ──────────────────────────────────────────────
|
||||
*
|
||||
* Two related-but-distinct dedup paths exist in the codebase:
|
||||
*
|
||||
* - `pickCanonicalWorkoutRows()` in `src/lib/measurements/` (v1.4.30)
|
||||
* — READ-time picker invoked by `GET /api/workouts` /
|
||||
* `GET /api/workouts/[id]`. It walks DB rows, looks up the user's
|
||||
* persisted `sourcePriorityJson`, buckets by 5-minute slot, and
|
||||
* filters the read result so the dashboard tile shows one row per
|
||||
* logical workout.
|
||||
*
|
||||
* - This helper (`dedupeWorkoutBatch`, v1.4.42) — WRITE-time picker
|
||||
* invoked by `POST /api/workouts/batch` BEFORE
|
||||
* `prisma.workout.createMany`. The name intentionally diverges
|
||||
* from the read-time picker to make the auto-completion path
|
||||
* unambiguous (write-time payload-internal dedup vs. read-time
|
||||
* cross-batch dedup with user-priority resolution).
|
||||
* The v1.5 iOS sprint will ingest `HKWorkoutType` data in batches
|
||||
* against `/api/workouts/batch`; Apple Watch + Withings ScanWatch
|
||||
* paired to the same iPhone frequently capture the same workout
|
||||
* simultaneously (e.g. a Sunday-morning run) and the iOS app may
|
||||
* post both rows in one batch. Without write-time dedup, both
|
||||
* rows would persist (their `externalId`s differ, so the
|
||||
* `(userId, source, externalId)` unique index doesn't fire) and
|
||||
* the read-time picker would carry the duplicate forward at every
|
||||
* downstream read.
|
||||
*
|
||||
* The two pickers are layered, not redundant:
|
||||
* - Write-time picker drops PAYLOAD-internal duplicates inside a
|
||||
* single batch so we don't pay the storage cost.
|
||||
* - Read-time picker still runs to collapse cross-batch duplicates
|
||||
* (e.g. Apple Watch row from Monday's batch + ScanWatch row from
|
||||
* Tuesday's backfill batch).
|
||||
*
|
||||
* Algorithm
|
||||
* ─────────
|
||||
*
|
||||
* 1. Group by `(userId, activityType, startedAt ± 90 s window)`.
|
||||
* The 90-second window is tighter than the read-time picker's
|
||||
* 5-minute slot because a single batch from one iOS client has
|
||||
* no cross-device clock skew to absorb — both rows are stamped
|
||||
* with HealthKit's `startDate`, which Apple aggregates to the
|
||||
* same instant across paired sensors. A 90-second cushion
|
||||
* covers HK's documented ±60s smoothing plus a small buffer.
|
||||
* 2. Groups containing one source are not duplicates and remain
|
||||
* unchanged. For a cross-source group, select the winning source
|
||||
* in order `APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT`, then keep
|
||||
* every row from that source. The literal source ladder comes from
|
||||
* `DEFAULT_WORKOUT_SOURCE_PRIORITY` so both pickers share one source
|
||||
* of truth.
|
||||
* 3. When two sources share a priority tier, prefer the source whose
|
||||
* best candidate has higher `caloriesKcal`, then earliest `createdAt`,
|
||||
* then input order. These tie-breakers choose a source; they never
|
||||
* collapse multiple workouts recorded by that winning source.
|
||||
*
|
||||
* Purity contract
|
||||
* ───────────────
|
||||
*
|
||||
* Zero Prisma imports, zero IO, zero user lookups. The helper takes
|
||||
* an array, returns an array. Testable in isolation; safely invoked
|
||||
* from a hot ingest path with no DB round-trip cost.
|
||||
*
|
||||
* Source-priority parity with the read-time picker
|
||||
* ────────────────────────────────────────────────
|
||||
*
|
||||
* CLOSED in v1.4.43 (W9-WORKOUTS-PRIORITY). The read-time picker
|
||||
* resolves the per-user `sourcePriorityJson` (from
|
||||
* `User.sourcePriorityJson`) for every read so a user who promoted
|
||||
* MANUAL above APPLE_HEALTH in Settings → Sources sees their Manual
|
||||
* rows surface above the Apple Watch ones. This helper now mirrors
|
||||
* that contract — the optional `userPriorityJson` argument feeds
|
||||
* through `parseSourcePriority()` + `getSourceLadder(..., "steps")`,
|
||||
* the same chain the read-time picker walks, so a customised user
|
||||
* keeps their preferred source at BOTH write- and read-time.
|
||||
*
|
||||
* When the argument is omitted (or null/undefined) the helper falls
|
||||
* back to the canonical `DEFAULT_WORKOUT_SOURCE_PRIORITY` ladder so
|
||||
* callers that don't yet have the user blob handy stay on the
|
||||
* v1.4.42 behaviour. The batch route at
|
||||
* `src/app/api/workouts/batch/route.ts` does one indexed
|
||||
* `User.sourcePriorityJson` lookup at the start of the handler and
|
||||
* passes the blob through here; the cost is a single `findUnique`
|
||||
* per batch, well under the cost of letting a customised user's
|
||||
* preferred row drop at write-time.
|
||||
*/
|
||||
|
||||
import { DEFAULT_WORKOUT_SOURCE_PRIORITY } from "@/lib/sources/pick-canonical-workout";
|
||||
import {
|
||||
getSourceLadder,
|
||||
parseSourcePriority,
|
||||
} from "@/lib/validations/source-priority";
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Minimum row shape consulted by the picker. Field names mirror the
|
||||
* batch-route's prepared `Prisma.WorkoutCreateManyInput` plus a
|
||||
* `createdAt` slot for the deterministic tie-breaker. Anything else
|
||||
* the caller carried alongside the row (route geometry, HR series,
|
||||
* metadata) survives untouched — the picker filters but never
|
||||
* mutates and is generic in `T` so call sites keep their own
|
||||
* carrier type.
|
||||
*
|
||||
* Naming aligned with the v1.5 iOS contract:
|
||||
* - `activityType` — the sport type (`"running"`, `"cycling"`,
|
||||
* ...). Matches `Workout.sportType` at the DB layer and
|
||||
* `WorkoutSportType` at the validator layer; cross-source
|
||||
* overlap requires identical sport so a "Walking" + "Running"
|
||||
* pair at the same instant stays as two distinct rows.
|
||||
* - `caloriesKcal` — the active-energy aggregate. The v1.5 iOS
|
||||
* contract uses `caloriesKcal` for parity with HealthKit's
|
||||
* `HKQuantityTypeIdentifierActiveEnergyBurned`; the server's
|
||||
* write path maps it to `Workout.totalEnergyKcal`.
|
||||
* - `createdAt` — when the row was minted. The batch path doesn't
|
||||
* have this yet (it's set by Prisma's `@default(now())` at
|
||||
* insert), so the helper falls back to insertion order via the
|
||||
* `index` field when `createdAt` is undefined on every member.
|
||||
*/
|
||||
export interface WorkoutRow {
|
||||
userId: string;
|
||||
activityType: string;
|
||||
startedAt: Date;
|
||||
source: MeasurementSource;
|
||||
caloriesKcal?: number | null;
|
||||
/**
|
||||
* Optional row-mint timestamp. The write-time picker is called
|
||||
* BEFORE `createMany`, so prepared rows usually have no
|
||||
* `createdAt` yet — undefined falls through to the next
|
||||
* tie-breaker (insertion order via the `index` field).
|
||||
*/
|
||||
createdAt?: Date | null;
|
||||
/**
|
||||
* Stable per-batch ordinal. Set by the caller (e.g. the position
|
||||
* of the entry in the inbound payload) so two rows with identical
|
||||
* priority + calories + createdAt still resolve deterministically.
|
||||
* Optional; defaults to 0 so single-row groups never need it.
|
||||
*/
|
||||
index?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-source dedup window. Within a single ingest batch from one
|
||||
* iOS client, two rows whose `startedAt` falls within ±90 s AND
|
||||
* share the same `activityType` AND belong to the same user are
|
||||
* treated as the same logical workout. The window is symmetric:
|
||||
* the comparison uses `|a.startedAt - b.startedAt| ≤ 90 000 ms`.
|
||||
*/
|
||||
export const WORKOUT_DEDUP_WINDOW_MS = 90 * 1000;
|
||||
|
||||
/**
|
||||
* Source-priority rank lookup. Lower rank = higher priority. A
|
||||
* source absent from the ladder gets a rank past the end of the
|
||||
* array — the picker still keeps that row when no ladder-listed
|
||||
* row competes for the slot (single-source group), but loses to
|
||||
* any ladder-listed competitor.
|
||||
*
|
||||
* The ladder is resolved per-call (from the user's
|
||||
* `sourcePriorityJson` or the canonical default), so the rank
|
||||
* function is parameterised on the ladder rather than reading a
|
||||
* module-level constant.
|
||||
*/
|
||||
function sourceRank(
|
||||
source: MeasurementSource,
|
||||
ladder: readonly MeasurementSource[],
|
||||
): number {
|
||||
const idx = ladder.indexOf(source);
|
||||
return idx === -1 ? ladder.length : idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator: returns negative when `a` should win over `b`.
|
||||
* Priority order: source rank → higher calories → earliest
|
||||
* createdAt → earliest input index.
|
||||
*/
|
||||
function compareCandidates<T extends WorkoutRow>(
|
||||
a: T,
|
||||
b: T,
|
||||
ladder: readonly MeasurementSource[],
|
||||
): number {
|
||||
const sourceDelta =
|
||||
sourceRank(a.source, ladder) - sourceRank(b.source, ladder);
|
||||
if (sourceDelta !== 0) return sourceDelta;
|
||||
|
||||
// Tie-breaker 1 — higher caloriesKcal wins (more sensor data).
|
||||
// `null` / `undefined` calories sort below any numeric value.
|
||||
const aCal = a.caloriesKcal ?? -1;
|
||||
const bCal = b.caloriesKcal ?? -1;
|
||||
if (aCal !== bCal) return bCal - aCal;
|
||||
|
||||
// Tie-breaker 2 — earliest `createdAt` wins (stable across
|
||||
// re-runs). Undefined sorts below any concrete date so a
|
||||
// pre-insert row (no createdAt yet) doesn't beat an already-
|
||||
// persisted row carrying a real timestamp.
|
||||
const aCreated = a.createdAt?.getTime() ?? Number.POSITIVE_INFINITY;
|
||||
const bCreated = b.createdAt?.getTime() ?? Number.POSITIVE_INFINITY;
|
||||
if (aCreated !== bCreated) return aCreated - bCreated;
|
||||
|
||||
// Tie-breaker 3 — input order. Guarantees determinism even when
|
||||
// every other field ties (rare; same source, same calories,
|
||||
// same / no createdAt). Default of 0 keeps single-row groups
|
||||
// untouched.
|
||||
return (a.index ?? 0) - (b.index ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick canonical rows across sources for an inbound ingest batch.
|
||||
*
|
||||
* Group by `(userId, activityType, startedAt ± 90 s)`; a group with
|
||||
* multiple sources keeps every row from its highest-priority source.
|
||||
* Single-source overlaps pass through unchanged. The returned
|
||||
* array preserves the input order of the surviving rows so a
|
||||
* caller that pre-sorted by `startedAt ASC` keeps that ordering
|
||||
* downstream.
|
||||
*
|
||||
* Implementation walks the input once with a union-find-style
|
||||
* pass: each row joins an existing group whose anchor falls
|
||||
* within the dedup window AND matches `(userId, activityType)`,
|
||||
* or seeds a new group. The anchor is the FIRST row that landed
|
||||
* in the group; subsequent rows compare against the anchor so the
|
||||
* window can't drift across an arbitrarily long chain of
|
||||
* within-window-of-neighbour rows. This is O(n²) worst case but
|
||||
* a typical batch is ≤ 100 entries so the constant is fine.
|
||||
*/
|
||||
export function dedupeWorkoutBatch<T extends WorkoutRow>(
|
||||
rows: readonly T[],
|
||||
userPriorityJson?: unknown,
|
||||
): T[] {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// Resolve the source ladder from the user's persisted preference
|
||||
// when one was supplied. The read-time picker
|
||||
// (`pickCanonicalWorkoutRows`) keys workouts off the `steps` metric
|
||||
// ladder — the canonical-activity bucket — so both pickers consult
|
||||
// the same per-user configuration surface. When no preference is
|
||||
// supplied (legacy callers, tests that don't care, or a route that
|
||||
// hasn't been wired yet), fall back to the canonical default
|
||||
// ladder so behaviour matches the v1.4.42 baseline.
|
||||
const ladder: readonly MeasurementSource[] =
|
||||
userPriorityJson == null
|
||||
? DEFAULT_WORKOUT_SOURCE_PRIORITY
|
||||
: getSourceLadder(parseSourcePriority(userPriorityJson), "steps");
|
||||
|
||||
// Stamp each input with its original index so the comparator
|
||||
// has a stable tie-breaker without mutating the caller's row.
|
||||
type Tagged = { row: T; origIndex: number };
|
||||
const tagged: Tagged[] = rows.map((row, origIndex) => ({ row, origIndex }));
|
||||
|
||||
// Group anchors: each group is a list of (row, origIndex). The
|
||||
// anchor row sets the window comparison reference.
|
||||
const groups: Tagged[][] = [];
|
||||
|
||||
for (const entry of [...tagged].sort((a, b) => {
|
||||
const delta = a.row.startedAt.getTime() - b.row.startedAt.getTime();
|
||||
return delta || a.origIndex - b.origIndex;
|
||||
})) {
|
||||
let placed = false;
|
||||
for (const group of groups) {
|
||||
const anchor = group[0]!.row;
|
||||
if (
|
||||
anchor.userId === entry.row.userId &&
|
||||
anchor.activityType === entry.row.activityType &&
|
||||
Math.abs(anchor.startedAt.getTime() - entry.row.startedAt.getTime()) <=
|
||||
WORKOUT_DEDUP_WINDOW_MS
|
||||
) {
|
||||
group.push(entry);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
groups.push([entry]);
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve single-source groups. For genuine cross-source overlaps,
|
||||
// select the winning source and retain every row it contributed.
|
||||
const survivors: Tagged[] = [];
|
||||
for (const group of groups) {
|
||||
if (group.length === 1) {
|
||||
survivors.push(group[0]!);
|
||||
continue;
|
||||
}
|
||||
const sources = new Set(group.map((entry) => entry.row.source));
|
||||
if (sources.size === 1) {
|
||||
survivors.push(...group);
|
||||
continue;
|
||||
}
|
||||
const annotated = group.map((entry) => ({
|
||||
...entry,
|
||||
row: {
|
||||
...entry.row,
|
||||
index: entry.row.index ?? entry.origIndex,
|
||||
} as T,
|
||||
}));
|
||||
annotated.sort((a, b) => compareCandidates(a.row, b.row, ladder));
|
||||
const winningSource = annotated[0]!.row.source;
|
||||
survivors.push(
|
||||
...group.filter((entry) => entry.row.source === winningSource),
|
||||
);
|
||||
}
|
||||
|
||||
survivors.sort((a, b) => a.origIndex - b.origIndex);
|
||||
return survivors.map((entry) => entry.row);
|
||||
}
|
||||
Reference in new issue
Block a user