refactor(workouts): rename write-time picker to dedupeWorkoutBatch

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 19:26:35 +02:00
1 parent 958e2491e4
commit a5176f4be9
3 files changed
+26 -22

No files matched your search

+2 -2
View File
@@ -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<Response> {
// 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,
@@ -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<TestRow> & { 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);
});
});
+7 -3
View File
@@ -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<T extends WorkoutRow>(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<T extends WorkoutRow>(
export function dedupeWorkoutBatch<T extends WorkoutRow>(
rows: readonly T[],
): T[] {
if (rows.length === 0) return [];