From a5176f4be9d2698f5e61c9c8a61c7dced5df7797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 19:25:27 +0200 Subject: [PATCH] refactor(workouts): rename write-time picker to dedupeWorkoutBatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.4.42 W5 helper shipped under the same identifier `pickCanonicalWorkoutRows` as the v1.4.30 read-time picker in `src/lib/measurements/`. Same name in two modules with different signatures (write-time takes no user-priority, read-time does) sets up the next contributor to auto-complete the name in a new route and silently invoke the wrong picker — no user-priority resolution on a read, missing the 90-second window on a write. Rename to `dedupeWorkoutBatch` so the write-time contract is self-describing. The read-time picker keeps its established name. One file's export + 12-case test suite + the batch-route caller update — no algorithm change. --- src/app/api/workouts/batch/route.ts | 4 +-- .../workouts/__tests__/canonical-rows.test.ts | 34 +++++++++---------- src/lib/workouts/canonical-rows.ts | 10 ++++-- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 67771ea7f..3fe1a47c6 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -68,7 +68,7 @@ import { createBatchWorkoutSchema, MAX_WORKOUTS_PER_BATCH, } from "@/lib/validations/workout"; -import { pickCanonicalWorkoutRows } from "@/lib/workouts/canonical-rows"; +import { dedupeWorkoutBatch } from "@/lib/workouts/canonical-rows"; import { Prisma, type MeasurementSource } from "@/generated/prisma/client"; // v1.4.25 W16c — push-suppression threshold for workout PRs. A batch @@ -270,7 +270,7 @@ async function postBatch(request: NextRequest): Promise { // dropped here surface to the iOS client as `duplicate` so the // sync cursor advances past them identically to the externalId // case below. - const canonicalPrepared = pickCanonicalWorkoutRows( + const canonicalPrepared = dedupeWorkoutBatch( prepared.map((p) => ({ userId: p.row.userId, activityType: p.row.sportType, diff --git a/src/lib/workouts/__tests__/canonical-rows.test.ts b/src/lib/workouts/__tests__/canonical-rows.test.ts index 0a724e164..1808652ff 100644 --- a/src/lib/workouts/__tests__/canonical-rows.test.ts +++ b/src/lib/workouts/__tests__/canonical-rows.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from "vitest"; import { - pickCanonicalWorkoutRows, + dedupeWorkoutBatch, WORKOUT_DEDUP_WINDOW_MS, type WorkoutRow, } from "../canonical-rows"; @@ -37,9 +37,9 @@ function row(partial: Partial & { id: string }): TestRow { }; } -describe("pickCanonicalWorkoutRows", () => { +describe("dedupeWorkoutBatch", () => { it("returns an empty array on empty input", () => { - expect(pickCanonicalWorkoutRows([])).toEqual([]); + expect(dedupeWorkoutBatch([])).toEqual([]); }); it("collapses exact duplicates from the same source", () => { @@ -61,7 +61,7 @@ describe("pickCanonicalWorkoutRows", () => { createdAt: new Date("2026-05-21T08:31:00Z"), }); - const out = pickCanonicalWorkoutRows([a, b]); + const out = dedupeWorkoutBatch([a, b]); expect(out).toHaveLength(1); // Same calories → earliest createdAt wins (`a`). expect(out[0]!.id).toBe("a"); @@ -85,7 +85,7 @@ describe("pickCanonicalWorkoutRows", () => { caloriesKcal: 500, }); - const out = pickCanonicalWorkoutRows([apple, withings]); + 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 @@ -106,7 +106,7 @@ describe("pickCanonicalWorkoutRows", () => { startedAt: new Date("2026-05-21T08:10:00Z"), }); - const out = pickCanonicalWorkoutRows([first, second]); + const out = dedupeWorkoutBatch([first, second]); expect(out.map((r) => r.id)).toEqual(["first", "second"]); }); @@ -125,7 +125,7 @@ describe("pickCanonicalWorkoutRows", () => { startedAt: new Date("2026-05-21T08:00:00Z"), }); - const out = pickCanonicalWorkoutRows([u1, u2]); + const out = dedupeWorkoutBatch([u1, u2]); expect(out.map((r) => r.id).sort()).toEqual(["u1", "u2"]); }); @@ -144,7 +144,7 @@ describe("pickCanonicalWorkoutRows", () => { startedAt: new Date("2026-05-21T08:00:00Z"), }); - const out = pickCanonicalWorkoutRows([walk, strength]); + const out = dedupeWorkoutBatch([walk, strength]); expect(out.map((r) => r.id).sort()).toEqual(["strength", "walk"]); }); @@ -173,12 +173,12 @@ describe("pickCanonicalWorkoutRows", () => { }); // Pair 1: hi vs lo → hi wins on calories. - expect(pickCanonicalWorkoutRows([lo, hi]).map((r) => r.id)).toEqual(["hi"]); + expect(dedupeWorkoutBatch([lo, hi]).map((r) => r.id)).toEqual(["hi"]); // Pair 2: hi vs lo-early → hi STILL wins because calories trump // createdAt. expect( - pickCanonicalWorkoutRows([loWithEarlierCreated, hi]).map((r) => r.id), + dedupeWorkoutBatch([loWithEarlierCreated, hi]).map((r) => r.id), ).toEqual(["hi"]); }); @@ -198,7 +198,7 @@ describe("pickCanonicalWorkoutRows", () => { createdAt: new Date("2026-05-21T08:45:00Z"), }); - expect(pickCanonicalWorkoutRows([late, early]).map((r) => r.id)).toEqual([ + expect(dedupeWorkoutBatch([late, early]).map((r) => r.id)).toEqual([ "early", ]); }); @@ -211,13 +211,13 @@ describe("pickCanonicalWorkoutRows", () => { const first = row({ id: "first", source: "MANUAL" }); const second = row({ id: "second", source: "MANUAL" }); - expect(pickCanonicalWorkoutRows([first, second]).map((r) => r.id)).toEqual([ + expect(dedupeWorkoutBatch([first, second]).map((r) => r.id)).toEqual([ "first", ]); // Reversed input → reversed survivor. Confirms the tie-breaker // genuinely consults input order rather than a hidden field // (e.g. `id` lexicographic). - expect(pickCanonicalWorkoutRows([second, first]).map((r) => r.id)).toEqual([ + expect(dedupeWorkoutBatch([second, first]).map((r) => r.id)).toEqual([ "second", ]); }); @@ -247,12 +247,12 @@ describe("pickCanonicalWorkoutRows", () => { // anchor + onBoundary → one group; APPLE_HEALTH wins. expect( - pickCanonicalWorkoutRows([anchor, onBoundary]).map((r) => r.id), + dedupeWorkoutBatch([anchor, onBoundary]).map((r) => r.id), ).toEqual(["anchor"]); // anchor + justOutside → two distinct groups, both survive. expect( - pickCanonicalWorkoutRows([anchor, justOutside]).map((r) => r.id), + dedupeWorkoutBatch([anchor, justOutside]).map((r) => r.id), ).toEqual(["anchor", "outside"]); }); @@ -275,7 +275,7 @@ describe("pickCanonicalWorkoutRows", () => { }), ); - expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual([ + expect(dedupeWorkoutBatch(rows).map((r) => r.id)).toEqual([ "APPLE_HEALTH", ]); }); @@ -287,7 +287,7 @@ describe("pickCanonicalWorkoutRows", () => { 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]); - pickCanonicalWorkoutRows([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 index 1d1174c4b..031e8b7cb 100644 --- a/src/lib/workouts/canonical-rows.ts +++ b/src/lib/workouts/canonical-rows.ts @@ -14,8 +14,12 @@ * filters the read result so the dashboard tile shows one row per * logical workout. * - * - This helper (v1.4.42) — WRITE-time picker invoked by - * `POST /api/workouts/batch` BEFORE `prisma.workout.createMany`. + * - 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 @@ -189,7 +193,7 @@ function compareCandidates(a: T, b: T): number { * 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 pickCanonicalWorkoutRows( +export function dedupeWorkoutBatch( rows: readonly T[], ): T[] { if (rows.length === 0) return [];