mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
958e2491e4
commit
a5176f4be9
3 files changed
+26
-22
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
|
||||
Reference in new issue
Block a user