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:
Marc-André Bombeck committed 2026-10-20 04:36:25 +02:00
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",
]);
});
});
-437
View File
@@ -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);
});
});
-308
View File
@@ -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);
}