diff --git a/src/__tests__/correlation-single-implementation-guard.test.ts b/src/__tests__/correlation-single-implementation-guard.test.ts index 2a4d1e09c..f9697ab84 100644 --- a/src/__tests__/correlation-single-implementation-guard.test.ts +++ b/src/__tests__/correlation-single-implementation-guard.test.ts @@ -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; diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index eb46135e0..75c083e73 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -133,6 +133,8 @@ const ALLOWED_SQL: Record = { "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 { diff --git a/src/__tests__/naive-timestamp-zone-shift-guard.test.ts b/src/__tests__/naive-timestamp-zone-shift-guard.test.ts index d0e55846c..cfb111b5a 100644 --- a/src/__tests__/naive-timestamp-zone-shift-guard.test.ts +++ b/src/__tests__/naive-timestamp-zone-shift-guard.test.ts @@ -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([]); }); }); diff --git a/src/lib/sources/__tests__/pick-canonical-workout.test.ts b/src/lib/sources/__tests__/pick-canonical-workout.test.ts deleted file mode 100644 index ecb9afbda..000000000 --- a/src/lib/sources/__tests__/pick-canonical-workout.test.ts +++ /dev/null @@ -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 => ({ - 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", - ]); - }); -}); diff --git a/src/lib/sources/pick-canonical-workout.ts b/src/lib/sources/pick-canonical-workout.ts deleted file mode 100644 index 9f1da7274..000000000 --- a/src/lib/sources/pick-canonical-workout.ts +++ /dev/null @@ -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( - 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( - 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( - 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( - 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 }; -} diff --git a/src/lib/workouts/__tests__/canonical-rows.test.ts b/src/lib/workouts/__tests__/canonical-rows.test.ts deleted file mode 100644 index 4250b906a..000000000 --- a/src/lib/workouts/__tests__/canonical-rows.test.ts +++ /dev/null @@ -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 & { 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); - }); -}); diff --git a/src/lib/workouts/canonical-rows.ts b/src/lib/workouts/canonical-rows.ts deleted file mode 100644 index b6fbf1688..000000000 --- a/src/lib/workouts/canonical-rows.ts +++ /dev/null @@ -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( - 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( - 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); -}