From 466dfb3785b996b65d8b2427b2e4e21c8c5bf88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 18:21:02 +0200 Subject: [PATCH] fix(workouts): resolve duplicate clusters the same way on every surface The cross-source picker anchors each duplicate cluster on its first start, so which cluster a workout lands in depends on the workouts around it. The detail route still read the old fixed five-minute clock slot, and every other reader handed the picker only the rows of its own range. The detail of a twin that straddled a slot boundary declared itself canonical while the list hid it, and runs at 09:58, 10:02 and 10:06 were grouped one way by a read starting at 09:55 and another by a read starting at 10:00. Every reader that decides which workout is canonical (list, detail and its previous session, activity calendar, day view, doctor report, Coach, briefing and correlation blocks, strain, per-sport context, the activity insight's history, the batch write) now reads through one windowed reader. It reads a duplicate window past the end of the range and reaches back until every sport in the range starts at a workout that is an anchor whatever came before it, then picks over everything read and keeps the rows that start inside the range. The per-sport baseline and the insight history leave out the whole cluster of the current session instead of the row alone, which left its twin behind as a session of its own. The batch write clusters its rows together with the stored workouts around them and drops only a row that loses to another row of the same batch. A property test checks the windowed read against a whole-history read for random dense histories and ranges; route tests pin list, detail, deep link, previous session and the batch write against the database. --- docs/api/openapi.yaml | 8 +- .../baselines/source-file-lines.json | 11 +- .../baselines/source-function-lines.json | 8 +- src/__tests__/baselines/test-as-never.json | 4 +- .../baselines/version-stamp-comments.json | 4 +- .../workout-canonical-identity-guard.test.ts | 95 +++-- src/app/api/workouts/[id]/route.ts | 185 ++++---- .../workouts/__tests__/batch-create.test.ts | 21 +- .../__tests__/canonical-dedup.test.ts | 6 +- src/app/api/workouts/batch/route.ts | 33 +- src/lib/activity/calendar-read.ts | 40 +- .../__tests__/snapshot-new-metrics.test.ts | 6 +- .../__tests__/workouts-block-units.test.ts | 29 +- .../coach/snapshot-blocks/workouts-block.ts | 10 +- .../ai/coach/snapshot-blocks/workouts-read.ts | 30 ++ src/lib/ai/coach/snapshot.ts | 19 +- src/lib/day/records.ts | 33 +- src/lib/doctor-report/workouts.ts | 38 +- .../insights/__tests__/strain-score.test.ts | 2 +- .../insights/correlation-channel-series.ts | 28 +- src/lib/insights/feature-blocks.ts | 34 +- src/lib/insights/strain-score.ts | 122 ++---- .../workout-insight-generate.test.ts | 40 +- src/lib/jobs/workout-insight-generate.ts | 61 +-- ...ck-canonical-workout-rows.property.test.ts | 2 +- .../pick-canonical-workout-rows.ts | 16 +- src/lib/openapi/routes/workouts.ts | 2 +- .../__tests__/canonical-read.property.test.ts | 240 +++++++++++ src/lib/workouts/batch-dedup.ts | 73 ++++ src/lib/workouts/canonical-read.ts | 395 ++++++++++++++++++ src/lib/workouts/list-read.ts | 97 +++-- src/lib/workouts/sport-context.ts | 51 ++- .../routes/workouts-canonical-window.test.ts | 215 ++++++++++ 33 files changed, 1486 insertions(+), 472 deletions(-) create mode 100644 src/lib/ai/coach/snapshot-blocks/workouts-read.ts create mode 100644 src/lib/workouts/__tests__/canonical-read.property.test.ts create mode 100644 src/lib/workouts/batch-dedup.ts create mode 100644 src/lib/workouts/canonical-read.ts create mode 100644 tests/integration/routes/workouts-canonical-window.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b19c7de6c..b2d97d4bd 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4985,9 +4985,11 @@ paths: - Measurements summary: List workouts (v1.4.32) description: "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user - source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + - Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: - `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`." + source-priority ladder and clusters workouts of one sport whose starts lie less than five minutes after the + cluster's first start — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per + cluster. A twin just outside `since` / `until` still decides which row inside the range is canonical, so the + list agrees with `canonicalId` on the detail. Field names mirror the iOS handoff contract: `distanceM`, + `activeEnergyKcal`, `avgHr`, `maxHr`." parameters: - in: query name: limit diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 1dc83d219..901c7d2aa 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 124, - "total": 146512, + "count": 123, + "total": 145651, "entries": { "app/api/dashboard/summary/route.ts": 1032, "app/api/documents/inbound/route.ts": 811, @@ -10,7 +10,7 @@ "app/api/measurements/route.ts": 938, "app/api/medications/[id]/route.ts": 1281, "app/api/medications/intake/bulk/route.ts": 1044, - "app/api/workouts/batch/route.ts": 971, + "app/api/workouts/batch/route.ts": 958, "app/medications/page-client.tsx": 839, "app/page-client.tsx": 2324, "components/admin/backups-section.tsx": 1462, @@ -44,7 +44,7 @@ "lib/ai/coach/coach-prose-grounding.ts": 1079, "lib/ai/coach/persistence.ts": 1219, "lib/ai/coach/results/metric-table-tool.ts": 1102, - "lib/ai/coach/snapshot.ts": 1362, + "lib/ai/coach/snapshot.ts": 1347, "lib/ai/coach/stream-events.ts": 817, "lib/ai/coach/tools/executor.ts": 1522, "lib/ai/coach/turn/model.ts": 820, @@ -65,7 +65,7 @@ "lib/daily/today-overview.ts": 838, "lib/dashboard-layout.ts": 971, "lib/dashboard/snapshot.ts": 1550, - "lib/day/records.ts": 1175, + "lib/day/records.ts": 1174, "lib/doctor-report/collect.ts": 803, "lib/export/backup-plan.ts": 853, "lib/export/coach-backup.ts": 1070, @@ -90,7 +90,6 @@ "lib/insights/mood-aggregates.ts": 1055, "lib/insights/mood-status.ts": 814, "lib/insights/status-cache.ts": 812, - "lib/insights/strain-score.ts": 832, "lib/integrations/status.ts": 1445, "lib/jobs/apple-health-import-worker.ts": 863, "lib/jobs/backup-restore.ts": 888, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 86967afbb..c20379ec3 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 411, - "total": 148369, + "total": 148324, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -64,8 +64,8 @@ "app/api/sync/changes/route.ts :: apiHandler(arg)": 427, "app/api/user/ai-provider/route.ts :: apiHandler(arg) #2": 252, "app/api/withings/callback/route.ts :: apiHandler(arg)": 247, - "app/api/workouts/[id]/route.ts :: apiHandler(arg)": 313, - "app/api/workouts/batch/route.ts :: postBatch": 722, + "app/api/workouts/[id]/route.ts :: apiHandler(arg)": 297, + "app/api/workouts/batch/route.ts :: postBatch": 709, "app/auth/login/page.tsx :: LoginPage": 576, "app/auth/register/page.tsx :: RegisterPage": 285, "app/coach/conversations/page.tsx :: CoachConversationsBody": 249, @@ -287,7 +287,7 @@ "lib/ai/anthropic-client.ts :: generateCompletion": 294, "lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218, "lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218, - "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 898, + "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 882, "lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438, "lib/ai/coach/turn/model.ts :: runTurnModel": 560, "lib/ai/coach/turn/pipeline.ts :: runTurn": 274, diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index ede0dd3fb..e852565a5 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -1,7 +1,7 @@ { "$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", "count": 524, - "total": 5496, + "total": 5495, "entries": { "__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/mfa/verify.test.ts": 9, @@ -253,7 +253,7 @@ "app/api/withings/credentials/__tests__/route.test.ts": 4, "app/api/withings/webhook/__tests__/route.test.ts": 26, "app/api/withings/webhook/[token]/__tests__/route.test.ts": 8, - "app/api/workouts/__tests__/batch-create.test.ts": 14, + "app/api/workouts/__tests__/batch-create.test.ts": 13, "app/api/workouts/__tests__/canonical-dedup.test.ts": 20, "app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 4, "app/c/[token]/__tests__/report-download.test.ts": 11, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 37e3bc44e..aa461064a 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2959, - "total": 10521, + "total": 10520, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1710,7 +1710,7 @@ "lib/ai/coach/snapshot-cache.ts": 2, "lib/ai/coach/snapshot-prelude.ts": 17, "lib/ai/coach/snapshot-series.ts": 5, - "lib/ai/coach/snapshot.ts": 42, + "lib/ai/coach/snapshot.ts": 41, "lib/ai/coach/source-measurement-types.ts": 1, "lib/ai/coach/suggest-action.ts": 1, "lib/ai/coach/suggest-gate.ts": 1, diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 810a59174..88d05da85 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -31,11 +31,21 @@ const SRC = join(process.cwd(), "src"); const PICKER = "lib/measurements/pick-canonical-workout-rows.ts"; /** - * Every production call site of the picker, frozen. + * The only module that hands rows to the picker: the windowed reader, which + * picks over rows it read with the duplicate-window margin. Every surface goes + * through it, and its output types make a read without the margin a compile + * error. The strain engine's per-cluster impulse sum lives there too and is + * handed the rows of the engine's own windowed read. + */ +const PICKER_CALLERS = ["lib/workouts/canonical-read.ts"]; + +/** + * Every production read that decides which workout is canonical, frozen. * - * Adding a surface that collapses workouts is a deliberate act: it must fetch - * the identity fields or it silently disagrees with every other surface. Adding - * the file here is the acknowledgement. + * Adding a surface that collapses workouts is a deliberate act: it must read + * through `readWorkoutsAroundRange` and fetch the identity fields, or it + * silently disagrees with every other surface. Adding the file here is the + * acknowledgement. The value is the file that owns the read's `select`. */ const IDENTITY_OWNER: Record = { "app/api/workouts/[id]/route.ts": "app/api/workouts/[id]/route.ts", @@ -49,15 +59,15 @@ const IDENTITY_OWNER: Record = { // The activity calendar counts workouts per day; a session two devices // recorded must count once there, as it does in the list. "lib/activity/calendar-read.ts": "lib/activity/calendar-read.ts", - // The only indirection in the set. The Coach block is handed rows it does not - // read itself; the snapshot builder fetches them in parallel with every other - // block's data. Recording the owner here rather than assuming the call site - // owns its own select is the whole point of this map. - "lib/ai/coach/snapshot-blocks/workouts-block.ts": "lib/ai/coach/snapshot.ts", - // The write path: the batch route runs the same picker over the rows it is - // about to insert, so a duplicate inside one batch is dropped by the rule - // the list later applies. - "app/api/workouts/batch/route.ts": "app/api/workouts/batch/route.ts", + // The Coach block is handed rows it does not read itself; the snapshot + // builder fetches them through this read in parallel with every other + // block's data. + "lib/ai/coach/snapshot-blocks/workouts-read.ts": + "lib/ai/coach/snapshot-blocks/workouts-read.ts", + // The write path: the batch route clusters the rows it is about to insert + // together with the stored rows around them, so a duplicate is dropped by + // the rule the list later applies. + "lib/workouts/batch-dedup.ts": "lib/workouts/batch-dedup.ts", // The day-total training impulse, the briefing's workout tally and the day // view's workout section: each counted a session synced by two services // twice while the list showed it once. @@ -72,10 +82,16 @@ const IDENTITY_OWNER: Record = { /** * How a call site proves its rows carry the duration. A read selects the - * column; the batch route maps it off the row it computed for the insert. + * column; the batch route also maps it off the row it computed for the insert. */ -const IDENTITY_PROOF: Record = { - "app/api/workouts/batch/route.ts": "durationSec: p.row.durationSec", +const IDENTITY_PROOF: Record = { + // These two select `WORKOUT_PICK_SELECT`, whose duration is checked below. + "lib/day/records.ts": ["select: WORKOUT_PICK_SELECT"], + "lib/insights/correlation-channel-series.ts": ["select: WORKOUT_PICK_SELECT"], + "lib/workouts/batch-dedup.ts": [ + "durationSec: true", + "durationSec: p.row.durationSec", + ], }; const EXPECTED_CALL_SITES = Object.keys(IDENTITY_OWNER).sort(); @@ -93,17 +109,13 @@ function read(rel: string): string { } /** - * Lines that actually invoke the picker. Comment lines are excluded: a - * docblock that names the picker is documentation rather than a call. + * Lines that actually invoke `call`. Comment lines are excluded: a docblock + * that names the function is documentation rather than a call. */ -function invokesPicker(source: string): boolean { +function invokes(source: string, call: string): boolean { return source .split("\n") - .some( - (line) => - line.includes("pickCanonicalWorkoutRows(") && - !/^\s*(\*|\/\/|\/\*)/.test(line), - ); + .some((line) => line.includes(call) && !/^\s*(\*|\/\/|\/\*)/.test(line)); } describe("the picker's call-site set is frozen", () => { @@ -114,13 +126,29 @@ describe("the picker's call-site set is frozen", () => { expect(sourceFiles().length).toBeGreaterThan(1500); }); - it("has exactly the call sites this file acknowledges", () => { + it("hands rows to the picker only from the windowed reader", () => { const found = sourceFiles() .filter((rel) => rel !== PICKER) - .filter((rel) => invokesPicker(read(rel))); + .filter((rel) => invokes(read(rel), "pickCanonicalWorkoutRows(")); + + expect(found).toEqual(PICKER_CALLERS); + }); + + it("has exactly the windowed reads this file acknowledges", () => { + const found = sourceFiles() + .filter((rel) => rel !== "lib/workouts/canonical-read.ts") + .filter((rel) => invokes(read(rel), "readWorkoutsAroundRange(")); expect(found).toEqual(EXPECTED_CALL_SITES); }); + + it("scores strain only on the sessions of the windowed range", () => { + // The strain engine sums impulses over the rows of its windowed read; it + // must keep the sessions its range owns, not every cluster the margin read. + expect(read("lib/insights/strain-score.ts")).toContain( + "(at) => startsInRange(workouts.range, at)", + ); + }); }); describe("every call site fetches the session identity", () => { @@ -130,9 +158,20 @@ describe("every call site fetches the session identity", () => { // rows came from. Coarse in the safe direction — it can only fail to catch // a second unrelated select in the same file, never pass a caller that // fetches no duration at all. - expect(read(IDENTITY_OWNER[rel])).toContain( - IDENTITY_PROOF[rel] ?? "durationSec: true", + for (const proof of IDENTITY_PROOF[rel] ?? ["durationSec: true"]) { + expect(read(IDENTITY_OWNER[rel])).toContain(proof); + } + }); +}); + +describe("the shared pick select carries the identity", () => { + it("selects durationSec", () => { + const reader = read("lib/workouts/canonical-read.ts"); + const select = reader.slice( + reader.indexOf("export const WORKOUT_PICK_SELECT"), + reader.indexOf("} as const;"), ); + expect(select).toContain("durationSec: true"); }); }); diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 428c03c59..274abaefd 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -6,9 +6,9 @@ * canonical envelope per workout. The endpoint: * * - Returns the canonical row when the requested id wins its - * `(startedAt slot, sportType)` cluster against the user's - * source-priority ladder. The picker reuses - * `pickCanonicalWorkoutRows()` from v1.4.30 so the dedup contract + * duplicate cluster against the user's source-priority ladder. The + * cluster is resolved through the same windowed read the list uses + * (`src/lib/workouts/canonical-read.ts`), so the dedup contract * stays in sync with the list endpoint. * - When the requested id is a non-canonical twin (e.g. the user * opened a deep-link to a Withings row but the cluster's winner is @@ -40,7 +40,14 @@ import { lockPersonalRecordsForUser, workoutPrSlotsForSport, } from "@/lib/personal-records/pr-detection-worker"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalClusterOf, + canonicalWorkoutsInRange, + readWorkoutsAroundRange, + type CanonicalReadRow, + type WorkoutBoundedRead, + type WorkoutStartBounds, +} from "@/lib/workouts/canonical-read"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; @@ -60,6 +67,38 @@ import { userDayKey } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; +/** How many earlier rows one step of the previous-session search reads. */ +const PREVIOUS_SESSION_BATCH = 20; +/** Steps before the search gives up; each reaches past the last one. */ +const PREVIOUS_SESSION_MAX_STEPS = 5; + +/** + * The newest canonical workout of the sport that starts before `before` (the + * first start of the current session's cluster). A batch of earlier rows names + * the span to resolve; the span is read through the windowed reader so its + * twins are resolved the way the list resolves them. A span whose rows are all + * hidden by a twin further back moves the search back one batch. + */ +async function previousCanonicalWorkoutId( + read: WorkoutBoundedRead, + latestStartsBefore: (to: Date, take: number) => Promise, + before: Date, + priorityJson: unknown, +): Promise { + let to = before; + for (let step = 0; step < PREVIOUS_SESSION_MAX_STEPS; step += 1) { + const earlier = await latestStartsBefore(to, PREVIOUS_SESSION_BATCH); + if (earlier.length === 0) return null; + const from = earlier[earlier.length - 1]!; + const windowed = await readWorkoutsAroundRange({ from, to: before }, read); + const canonical = canonicalWorkoutsInRange(windowed, priorityJson); + const newest = canonical[canonical.length - 1]; + if (newest) return newest.id; + to = from; + } + return null; +} + export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -113,38 +152,38 @@ export const GET = apiHandler( return apiError("Workout not found", 404); } - // Resolve the canonical winner for the row's cluster. The picker - // reads every workout in the same 5-minute slot and sport-type - // bucket; in practice that's ≤ 4 rows (Apple + Withings + the two - // legacy sources). The query is bounded so the round-trip is cheap. - const slotMs = 5 * 60 * 1000; - const slotStart = new Date( - Math.floor(row.startedAt.getTime() / slotMs) * slotMs, + // Resolve the canonical winner for the row's cluster. The read goes + // through the same windowed reader as the list, so the cluster drawn here + // is the one the list draws: a twin that straddles a clock slot boundary, + // or one whose cluster begins with an earlier workout, resolves to the row + // the list shows. + const readSameSport = (bounds: WorkoutStartBounds) => + prisma.workout.findMany({ + where: { + userId: user.id, + sportType: row.sportType, + startedAt: bounds, + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + // Part of the canonical session identity consulted by the picker — + // without it a HealthKit re-send of this very workout stays in the + // cluster and the deep link can resolve to the duplicate instead of + // the row the list shows. + durationSec: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + }, + }); + const around = await readWorkoutsAroundRange( + { from: row.startedAt, to: row.startedAt, inclusiveEnd: true }, + readSameSport, ); - const slotEnd = new Date(slotStart.getTime() + slotMs); - - const clusterRows = await prisma.workout.findMany({ - where: { - userId: user.id, - sportType: row.sportType, - startedAt: { gte: slotStart, lt: slotEnd }, - }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - select: { - id: true, - source: true, - startedAt: true, - sportType: true, - // Part of the canonical session identity consulted by - // `pickCanonicalWorkoutRows` below — without it a HealthKit re-send of - // this very workout stays in the cluster and the deep link can resolve - // to the duplicate instead of the row the list shows. - durationSec: true, - avgHeartRate: true, - maxHeartRate: true, - metadata: true, - }, - }); const userRow = await prisma.user.findUnique({ where: { id: user.id }, @@ -158,17 +197,15 @@ export const GET = apiHandler( const timezone = await resolveUserTimezone(user.id); const dayKey = userDayKey(row.startedAt, timezone); - const canonicalCluster = pickCanonicalWorkoutRows( - clusterRows, + const cluster = canonicalClusterOf( + around, + row.id, userRow?.sourcePriorityJson ?? null, ); - // The canonical pick may carry multiple rows (same winning source); - // pick the closest in time to the requested row so deep-link - // redirects land on the most similar twin. - const canonical = - canonicalCluster.find((c) => c.id === row.id) ?? - canonicalCluster[0] ?? - null; + // The cluster's canonical rows, the requested row first when it is one of + // them and otherwise the nearest in time, so a deep link to a hidden twin + // lands on the most similar row. + const canonical = cluster?.canonical[0] ?? null; const effectiveAvgHeartRate = canonical?.avgHeartRate ?? row.avgHeartRate; const effectiveMaxHeartRate = canonical?.maxHeartRate ?? row.maxHeartRate; @@ -233,43 +270,29 @@ export const GET = apiHandler( // The session before this one in the same sport. "How does this compare // to last time?" is the most likely next question after reading a // workout, and today it costs a trip back to the list plus a scan. - // Scoped to rows that start before this cluster's slot, so a twin of THIS - // session can never be offered as the previous one, and run through the - // same canonical picker so the link lands on the row the list shows. - const previousRows = await prisma.workout.findMany({ - where: { - userId: user.id, - sportType: row.sportType, - startedAt: { lt: slotStart }, - }, - orderBy: [{ startedAt: "desc" }, { id: "desc" }], - // Enough to survive a run of same-session twins and re-sends without - // reading the whole history; the picker collapses them to a handful. - take: 20, - select: { - id: true, - source: true, - startedAt: true, - sportType: true, - durationSec: true, - avgHeartRate: true, - maxHeartRate: true, - metadata: true, - }, - }); - // The picker takes rows in ascending order and does not promise to hand - // them back sorted, so pick the newest survivor explicitly. - const previousWorkoutId = - pickCanonicalWorkoutRows( - [...previousRows].reverse(), - userRow?.sourcePriorityJson ?? null, - ).reduce<{ id: string; startedAt: Date } | null>( - (newest, candidate) => - newest === null || candidate.startedAt > newest.startedAt - ? candidate - : newest, - null, - )?.id ?? null; + // Scoped to rows that start before this session's cluster, so a twin of + // THIS session can never be offered as the previous one, and resolved + // through the same windowed read so the link lands on the row the list + // shows. + const clusterStart = cluster?.members[0]?.startedAt ?? row.startedAt; + const previousWorkoutId = await previousCanonicalWorkoutId( + readSameSport, + async (to, take) => + ( + await prisma.workout.findMany({ + where: { + userId: user.id, + sportType: row.sportType, + startedAt: { lt: to }, + }, + orderBy: [{ startedAt: "desc" }, { id: "desc" }], + take, + select: { startedAt: true }, + }) + ).map((earlier) => earlier.startedAt), + clusterStart, + userRow?.sourcePriorityJson ?? null, + ); // A route whose track cannot be read (a sealed value that no longer // opens, or an old row stored as a JSON null) is reported as no route at diff --git a/src/app/api/workouts/__tests__/batch-create.test.ts b/src/app/api/workouts/__tests__/batch-create.test.ts index ecdc3b436..877dee0cc 100644 --- a/src/app/api/workouts/__tests__/batch-create.test.ts +++ b/src/app/api/workouts/__tests__/batch-create.test.ts @@ -128,6 +128,19 @@ function mockInsertReturning( }) => (args.where === undefined && args.select?.id ? rows : [])) as never); } +/** + * Rows the pre-flight `(source, externalId)` probe finds. The probe is the + * read with an `OR` of keys; the windowed read of the stored workouts around + * the batch (a `startedAt` range) finds nothing here. + */ +function mockExistingByKey( + rows: Array<{ source: string; externalId: string }>, +) { + vi.mocked(prisma.workout.findMany).mockImplementation((async (args: { + where?: { OR?: unknown }; + }) => (args.where?.OR ? rows : [])) as never); +} + function validWorkout( externalId: string, overrides: Record = {}, @@ -345,9 +358,9 @@ describe("POST /api/workouts/batch — per-entry status envelope", () => { }); it("flags entries already in the DB as duplicate without re-inserting", async () => { - vi.mocked(prisma.workout.findMany).mockResolvedValue([ + mockExistingByKey([ { source: "APPLE_HEALTH", externalId: "uuid-existing" }, - ] as never); + ]); vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 }); const res = await POST( makeRequest({ @@ -825,9 +838,7 @@ describe("POST /api/workouts/batch — PR detection enqueue (v1.4.25 W16c)", () // stays 0 but duplicateCount becomes 1. The hook fires anyway — // the detector is cheap and a duplicate batch can still reveal a // PR if the row was written by another path between dispatches. - vi.mocked(prisma.workout.findMany).mockResolvedValue([ - { source: "APPLE_HEALTH", externalId: "uuid-dup" } as never, - ]); + mockExistingByKey([{ source: "APPLE_HEALTH", externalId: "uuid-dup" }]); vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 }); const res = await POST( makeRequest({ workouts: [validWorkout("uuid-dup")] }), diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index 1386b5d61..1dff5e968 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -358,9 +358,11 @@ describe("GET /api/workouts — canonical dedup", () => { where: { userId: "user-1", sportType: "running", + // The range plus one duplicate window on either side, so a twin + // just outside the filter still decides which row is canonical. startedAt: { - gte: new Date("2026-01-01T00:00:00.000Z"), - lte: new Date("2026-01-02T00:00:00.000Z"), + gte: new Date("2025-12-31T23:55:00.000Z"), + lt: new Date("2026-01-02T00:05:00.000Z"), }, }, }), diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 90c88cb54..cc07172c3 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -92,11 +92,11 @@ import { UNSTABLE_EXTERNAL_ID_REASON, type UnstableExternalIdShape, } from "@/lib/validations/external-id"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { batchWorkoutIndicesToKeep } from "@/lib/workouts/batch-dedup"; import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes"; import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; -import { Prisma, type MeasurementSource } from "@/generated/prisma/client"; +import { Prisma } from "@/generated/prisma/client"; // v1.4.25 — push-suppression threshold for workout PRs. A batch // larger than this fires the detection job with `silent: true` so a @@ -508,29 +508,16 @@ async function postBatch(request: NextRequest): Promise { }); } - // Write-time cross-source dedup through the one workout picker the reads - // use, with the same source ladder: one logical workout from two sources - // in one batch (or one HealthKit session re-sent under a new id) lands - // once. Rows the picker hides surface to the client as `duplicate`, so - // the sync cursor advances past them like the externalId case below. - const canonicalPrepared = pickCanonicalWorkoutRows( - prepared.map((p) => ({ - index: p.index, - userId: p.row.userId, - sportType: p.row.sportType, - startedAt: p.row.startedAt as Date, - durationSec: p.row.durationSec, - // Zod's `measurementSourceEnum.optional().default("MANUAL")` - // guarantees `source` is set post-parse, but Prisma's - // `WorkoutCreateManyInput` types it optional. - source: (p.row.source ?? "MANUAL") as MeasurementSource, - avgHeartRate: p.row.avgHeartRate ?? null, - maxHeartRate: p.row.maxHeartRate ?? null, - metadata: p.row.metadata ?? null, - })), + // Write-time cross-source dedup through the one workout clustering the + // reads use (see `batchWorkoutIndicesToKeep`): one logical workout from two + // sources in one batch, or one HealthKit session re-sent under a new id, + // lands once. Rows hidden here surface to the client as `duplicate`, so the + // sync cursor advances past them like the externalId case below. + const survivingIndices = await batchWorkoutIndicesToKeep( + user.id, + prepared, userPriority?.sourcePriorityJson ?? null, ); - const survivingIndices = new Set(canonicalPrepared.map((r) => r.index)); const droppedByWriteDedup: number[] = []; for (const p of prepared) { if (!survivingIndices.has(p.index)) { diff --git a/src/lib/activity/calendar-read.ts b/src/lib/activity/calendar-read.ts index 470fc0aa9..8fc919d0d 100644 --- a/src/lib/activity/calendar-read.ts +++ b/src/lib/activity/calendar-read.ts @@ -15,7 +15,10 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, +} from "@/lib/workouts/canonical-read"; import { seriesRowsFrom } from "@/lib/measurements/series-canonical"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; @@ -118,26 +121,31 @@ export async function readActivityDays(args: { ), ), includeWorkouts - ? prisma.workout.findMany({ - where: { userId, startedAt: { gte: from, lt: to } }, - select: { - startedAt: true, - durationSec: true, - sportType: true, - source: true, - avgHeartRate: true, - maxHeartRate: true, - metadata: true, - }, - orderBy: { startedAt: "asc" }, - }) - : Promise.resolve([]), + ? readWorkoutsAroundRange({ from, to }, (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + select: { + id: true, + startedAt: true, + durationSec: true, + sportType: true, + source: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + }), + ) + : Promise.resolve(null), ]); const loadIndex = loads.findIndex((m) => m.size > 0); const load = loadIndex >= 0 ? loads[loadIndex] : null; - const workouts = pickCanonicalWorkoutRows(workoutRows, priorityJson); + const workouts = workoutRows + ? canonicalWorkoutsInRange(workoutRows, priorityJson) + : []; const workoutsByDay = new Map< string, { minutes: number; count: number; bySport: Record } diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index 536b31ec6..97f0e68be 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -372,9 +372,12 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => // collapse, so a dual-device user's Coach prompt double-counted the // session and inflated `perSport`/`totalInWindow` vs. the app. Pin the fix. it("collapses a same-session cross-source workout pair to one, matching the UI", async () => { - const sharedStart = new Date("2026-06-03T06:30:00Z"); + // Inside the 30-day window: the block keeps only the sessions that + // start in it. + const sharedStart = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000); const workouts = [ { + id: "w-apple", sportType: "RUNNING", startedAt: sharedStart, durationSec: 1800, @@ -385,6 +388,7 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => source: "APPLE_HEALTH", }, { + id: "w-withings", sportType: "RUNNING", // 90 s apart — same 5-minute bucket the picker uses. startedAt: new Date(sharedStart.getTime() + 90_000), diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts index 9ed3415a4..29c495b94 100644 --- a/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/workouts-block-units.test.ts @@ -12,18 +12,23 @@ import type { UnitPreference } from "@/lib/measurements/display-transform"; function build(unitPreference: UnitPreference) { const snapshot: Record = {}; buildWorkoutsBlock({ - workoutRows: [ - { - sportType: "running", - startedAt: new Date("2026-09-20T07:00:00Z"), - durationSec: 1800, - totalEnergyKcal: 300, - totalDistanceM: 5000, - avgHeartRate: 150, - maxHeartRate: 170, - source: "APPLE_HEALTH", - }, - ], + workoutRows: { + range: { from: null, to: null }, + __windowed: true, + rows: [ + { + id: "w1", + sportType: "running", + startedAt: new Date("2026-09-20T07:00:00Z"), + durationSec: 1800, + totalEnergyKcal: 300, + totalDistanceM: 5000, + avgHeartRate: 150, + maxHeartRate: 170, + source: "APPLE_HEALTH", + }, + ], + }, sourcePriorityJson: null, userTz: "UTC", snapshot, diff --git a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts index e3d3fd976..634a26833 100644 --- a/src/lib/ai/coach/snapshot-blocks/workouts-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/workouts-block.ts @@ -25,7 +25,10 @@ import { getQuantityTransform, type UnitPreference, } from "@/lib/measurements/display-transform"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + type WindowedWorkouts, +} from "@/lib/workouts/canonical-read"; import type { MeasurementSource } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; import { tzDayKey, tzWeekday } from "../snapshot-series"; @@ -43,7 +46,8 @@ import type { const WORKOUT_RECENT_CAP = 15; interface WorkoutsBlockContext { - workoutRows: Array<{ + workoutRows: WindowedWorkouts<{ + id: string; sportType: string; startedAt: Date; durationSec: number; @@ -85,7 +89,7 @@ export function buildWorkoutsBlock(ctx: Readonly): void { // `startedAt` ASC (its documented contract) — the builder wants // most-recent-first for the capped `recent` list, so re-establish // DESC order after the pick, same as `GET /api/workouts` does. - const workoutRows = pickCanonicalWorkoutRows( + const workoutRows = canonicalWorkoutsInRange( rawWorkoutRows, sourcePriorityJson ?? null, ).sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime()); diff --git a/src/lib/ai/coach/snapshot-blocks/workouts-read.ts b/src/lib/ai/coach/snapshot-blocks/workouts-read.ts new file mode 100644 index 000000000..5d32f56a9 --- /dev/null +++ b/src/lib/ai/coach/snapshot-blocks/workouts-read.ts @@ -0,0 +1,30 @@ +/** + * The workout rows the Coach's workouts block narrates, read through the + * windowed reader so a session two sources recorded collapses to the row the + * workout list shows, also at the edge of the Coach's window. + */ +import { prisma } from "@/lib/db"; +import { readWorkoutsAroundRange } from "@/lib/workouts/canonical-read"; + +export function readCoachWorkoutRows(userId: string, since: Date) { + return readWorkoutsAroundRange({ from: since, to: null }, (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + sportType: true, + startedAt: true, + durationSec: true, + totalEnergyKcal: true, + totalDistanceM: true, + avgHeartRate: true, + maxHeartRate: true, + // Needed by the canonical pick in the block builder to collapse a + // dual-source session (e.g. Apple Watch + WHOOP recording the same + // run) to one, matching `GET /api/workouts`. + source: true, + }, + }), + ); +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index c9140715b..57ca5fd1a 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -79,6 +79,7 @@ import { buildCoarseTimelineTail, type CoarseTimelineTail, } from "./snapshot-series"; +import { readCoachWorkoutRows } from "./snapshot-blocks/workouts-read"; import { buildWorkoutsBlock } from "./snapshot-blocks/workouts-block"; import { buildGlucoseBlock, @@ -567,23 +568,7 @@ async function buildCoachSnapshotImpl( ? readSleepStageRows(userId, sleepRhythmCutoff) : null; const workoutRowsPromise = sources.has("workouts") - ? prisma.workout.findMany({ - where: { userId, startedAt: { gte: additiveCutoff("workouts") } }, - orderBy: { startedAt: "desc" }, - select: { - sportType: true, - startedAt: true, - durationSec: true, - totalEnergyKcal: true, - totalDistanceM: true, - avgHeartRate: true, - maxHeartRate: true, - // v1.30.4 — needed by `pickCanonicalWorkoutRows` in the block - // builder to collapse a dual-source session (e.g. Apple Watch + - // WHOOP recording the same run) to one, matching `GET /api/workouts`. - source: true, - }, - }) + ? readCoachWorkoutRows(userId, additiveCutoff("workouts")) : null; // v1.17.0 — the glucose CLINICAL panel is a fixed 30-day clinical artifact, // identical to the one the insights panel + doctor report render. It must NOT diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 4d1cd2bf4..f490f35b1 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -42,7 +42,11 @@ import { effectiveMoodTz } from "@/lib/mood/date-key"; import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { visitPath } from "@/lib/encounters/visit-links"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, + WORKOUT_PICK_SELECT, +} from "@/lib/workouts/canonical-read"; /** Everything a section reader needs about the day. */ export interface DayFrame { @@ -906,28 +910,23 @@ async function assessmentsPart(frame: DayFrame): Promise { async function workoutsPart(frame: DayFrame): Promise { const [stored, account] = await Promise.all([ - prisma.workout.findMany({ - where: { - userId: frame.userId, - startedAt: { gte: frame.dayStart, lt: frame.dayEnd }, - }, - select: { - id: true, - source: true, - sportType: true, - startedAt: true, - durationSec: true, - }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - }), + // The windowed reader orders the rows itself. + readWorkoutsAroundRange( + { from: frame.dayStart, to: frame.dayEnd }, + (bounds) => + prisma.workout.findMany({ + where: { userId: frame.userId, startedAt: bounds }, + select: WORKOUT_PICK_SELECT, + }), + ), prisma.user.findUnique({ where: { id: frame.userId }, select: { sourcePriorityJson: true }, }), ]); // One session synced by two services is one workout on the day, as in the - // workout list it links to. - const rows = pickCanonicalWorkoutRows( + // workout list it links to, a twin across midnight included. + const rows = canonicalWorkoutsInRange( stored, account?.sourcePriorityJson ?? null, ); diff --git a/src/lib/doctor-report/workouts.ts b/src/lib/doctor-report/workouts.ts index dc20b1817..740807c02 100644 --- a/src/lib/doctor-report/workouts.ts +++ b/src/lib/doctor-report/workouts.ts @@ -11,7 +11,10 @@ * session two sources reported counts once. */ import { prisma } from "@/lib/db"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, +} from "@/lib/workouts/canonical-read"; import type { DoctorReportWorkouts } from "@/lib/doctor-report-types"; /** @@ -29,20 +32,25 @@ export async function loadReportWorkouts(params: { sourcePriorityJson: unknown; }): Promise { const { userId, start, end, days, sourcePriorityJson } = params; - const rows = await prisma.workout.findMany({ - where: { userId, startedAt: { gte: start, lte: end } }, - orderBy: { startedAt: "asc" }, - select: { - sportType: true, - startedAt: true, - durationSec: true, - source: true, - totalDistanceM: true, - totalEnergyKcal: true, - avgHeartRate: true, - }, - }); - const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson); + const rows = await readWorkoutsAroundRange( + { from: start, to: end, inclusiveEnd: true }, + (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + sportType: true, + startedAt: true, + durationSec: true, + source: true, + totalDistanceM: true, + totalEnergyKcal: true, + avgHeartRate: true, + }, + }), + ); + const canonical = canonicalWorkoutsInRange(rows, sourcePriorityJson); return summariseReportWorkouts(canonical, days); } diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index 03ea6f010..84c061b7a 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -9,6 +9,7 @@ * - the per-day idempotency key + insufficient-data gate. */ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { canonicalWorkoutTrimps } from "@/lib/workouts/canonical-read"; /** * The post-mutation tail reads the real `@/lib/db` client, not the fake this @@ -32,7 +33,6 @@ import { percentile, resolvePersonalReference, persistStrainScore, - canonicalWorkoutTrimps, canonicalDayActiveEnergy, STRAIN_ACTIVE_ENERGY_REFERENCE, STRAIN_SCORE_EXTERNAL_ID_PREFIX, diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index cce0f970f..ed386cc69 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -76,7 +76,11 @@ import { userDayKey, } from "@/lib/tz/format"; import { MIN_PAIRED_N } from "@/lib/insights/correlations"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, + WORKOUT_PICK_SELECT, +} from "@/lib/workouts/canonical-read"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import type { @@ -1257,18 +1261,14 @@ export async function fetchTrainingSeries( since: Date, ): Promise { const [workouts, account, trimp] = await Promise.all([ - prisma.workout.findMany({ - where: { userId, startedAt: { gte: since } }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - take: WORKOUT_READ_CAP, - select: { - id: true, - source: true, - sportType: true, - startedAt: true, - durationSec: true, - }, - }), + readWorkoutsAroundRange({ from: since, to: null }, (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + take: WORKOUT_READ_CAP, + select: WORKOUT_PICK_SELECT, + }), + ), prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, @@ -1281,7 +1281,7 @@ export async function fetchTrainingSeries( select: { day: true, dayTrimp: true }, }), ]); - const sessions = pickCanonicalWorkoutRows( + const sessions = canonicalWorkoutsInRange( workouts, account?.sourcePriorityJson ?? null, ); diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 5e3916870..f501f1825 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -28,7 +28,10 @@ import { readRollupBuckets, ROLLUP_FOLD_WINDOW_MS, } from "@/lib/rollups/measurement-rollups"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, +} from "@/lib/workouts/canonical-read"; import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; import type { AggregatedFeatures, @@ -537,18 +540,21 @@ export async function readWorkoutsBlock( ): Promise { const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY); const [stored, account] = await Promise.all([ - prisma.workout.findMany({ - where: { userId, startedAt: { gte: since } }, - orderBy: [{ startedAt: "desc" }, { id: "asc" }], - take: 2000, - select: { - source: true, - sportType: true, - startedAt: true, - durationSec: true, - totalDistanceM: true, - }, - }), + readWorkoutsAroundRange({ from: since, to: null }, (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], + take: 2000, + select: { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + totalDistanceM: true, + }, + }), + ), prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, @@ -558,7 +564,7 @@ export async function readWorkoutsBlock( // workout: the same collapse the workout list applies, so the count and the // minutes here match what the person sees there. The picker returns its rows // oldest first; the block reads the newest at index 0. - const rows = pickCanonicalWorkoutRows( + const rows = canonicalWorkoutsInRange( stored, account?.sourcePriorityJson ?? null, ).reverse(); diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 863776d17..b5a5240b2 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -73,11 +73,11 @@ import { } from "@/lib/insights/score-row"; import { shiftDateKey } from "@/lib/tz/format"; import { - pickCanonicalWorkoutRows, - workoutDuplicateClusterKeys, - workoutSourceLadder, - type WorkoutPickerRow, -} from "@/lib/measurements/pick-canonical-workout-rows"; + canonicalWorkoutTrimps, + readWorkoutsAroundRange, + rowsInRange, + startsInRange, +} from "@/lib/workouts/canonical-read"; import { pickCanonicalSourceRows, type SourcePickerRow, @@ -433,19 +433,23 @@ export async function computeStrainScore( // cross-source picker needs: one run recorded by the watch and synced again // through a second service is one training impulse, not two. const [workouts, account] = await Promise.all([ - prisma.workout.findMany({ - where: { userId, startedAt: { gte: dayStart, lt: dayEnd } }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - select: { - id: true, - source: true, - sportType: true, - startedAt: true, - durationSec: true, - samples: { select: { samples: true } }, - }, - take: 100, - }), + // Read through the windowed reader: a session whose twin lies across + // midnight is counted once, on the day the list shows it. + readWorkoutsAroundRange({ from: dayStart, to: dayEnd }, (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + samples: { select: { samples: true } }, + }, + take: 100, + }), + ), prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, @@ -465,7 +469,7 @@ export async function computeStrainScore( let dayTrimp = 0; let workoutsWithSeries = 0; if (canTrimp) { - const trimpOf = (w: (typeof workouts)[number]): number => { + const trimpOf = (w: (typeof workouts.rows)[number]): number => { const raw = w.samples?.samples; if (!Array.isArray(raw)) return 0; return banisterTrimp( @@ -476,9 +480,10 @@ export async function computeStrainScore( ); }; const sessions = canonicalWorkoutTrimps( - workouts, + workouts.rows, account?.sourcePriorityJson ?? null, trimpOf, + (at) => startsInRange(workouts.range, at), ); for (const trimp of sessions) { dayTrimp += trimp; @@ -535,7 +540,7 @@ export async function computeStrainScore( return { score: null, reason: - workouts.length > 0 && !canTrimp + rowsInRange(workouts).length > 0 && !canTrimp ? "insufficient_profile" : "insufficient_inputs", dayTrimp: 0, @@ -572,81 +577,6 @@ export function canonicalDayActiveEnergy< return best; } -/** - * One training impulse per workout SESSION, not per stored row. - * - * The same session can be stored more than once: the watch writes it to Apple - * Health and a second service (Strava, WHOOP, a ring) syncs it again, or - * HealthKit re-sends it under a new identifier. The workout list collapses - * those through `pickCanonicalWorkoutRows`; summing every row here counted the - * session twice in the day's TRIMP while the list showed it once. - * - * The picker chooses the row by source order, which says nothing about heart - * rate: the winning row can be one without a series while its dropped twin - * carries one. So the impulse of a slot is the sum over its picked rows, and - * only when that is zero the largest impulse among the slot's dropped rows. - * - * The picker keeps every row of a slot none of whose sources is on the - * ladder, so the picked rows of one slot can come from several sources. Only - * one source's rows count: the best-ranked on the ladder, a source off the - * ladder ranking below every listed one, and among sources off the ladder - * the one with the largest impulse. Rows of that one source still add up - * (two distinct sessions of the same sport in one duplicate cluster). - * Returns the positive impulses, one per session. Pure; exported for tests. - */ -export function canonicalWorkoutTrimps< - T extends WorkoutPickerRow & { id: string }, ->( - rows: readonly T[], - priorityJson: unknown, - trimpOf: (row: T) => number, -): number[] { - // By id: the picker may hand back a copy of a row (it fills a missing heart - // rate from a WHOOP twin). - const picked = new Set( - pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id), - ); - const ladder = workoutSourceLadder(priorityJson); - const rankOf = (source: string) => { - const index = ladder.indexOf(source as (typeof ladder)[number]); - return index === -1 ? ladder.length : index; - }; - const slots = new Map< - string, - { picked: Map; dropped: number } - >(); - const keys = workoutDuplicateClusterKeys(rows); - for (const [index, row] of rows.entries()) { - const slot = slots.get(keys[index]!) ?? { picked: new Map(), dropped: 0 }; - const trimp = trimpOf(row); - if (trimp > 0) { - if (picked.has(row.id)) { - slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + trimp); - } else { - slot.dropped = Math.max(slot.dropped, trimp); - } - } - slots.set(keys[index]!, slot); - } - const out: number[] = []; - for (const slot of slots.values()) { - let best: { rank: number; trimp: number } | null = null; - for (const [source, trimp] of slot.picked) { - const rank = rankOf(source); - if ( - best === null || - rank < best.rank || - (rank === best.rank && trimp > best.trimp) - ) { - best = { rank, trimp }; - } - } - const trimp = best !== null ? best.trimp : slot.dropped; - if (trimp > 0) out.push(trimp); - } - return out; -} - /** * Build the Strain profile (age + sex) and resting HR from the user row + * the latest resting-HR reading. `prisma` is the (worker) client. diff --git a/src/lib/jobs/__tests__/workout-insight-generate.test.ts b/src/lib/jobs/__tests__/workout-insight-generate.test.ts index f1443b204..95d54083f 100644 --- a/src/lib/jobs/__tests__/workout-insight-generate.test.ts +++ b/src/lib/jobs/__tests__/workout-insight-generate.test.ts @@ -50,8 +50,23 @@ vi.mock("@/lib/insights/status-provider", () => ({ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ encryptToBytes: vi.fn(() => new Uint8Array([1, 2, 3])), })); -vi.mock("@/lib/measurements/pick-canonical-workout-rows", () => ({ - pickCanonicalWorkoutRows: vi.fn((rows) => rows), +vi.mock("@/lib/workouts/canonical-read", () => ({ + readWorkoutsAroundRange: vi.fn( + async ( + range: { from: Date | null; to: Date | null }, + read: (bounds: { gte?: Date; lt?: Date }) => Promise, + ) => ({ + rows: await read({ + gte: range.from ?? undefined, + lt: range.to ?? undefined, + }), + range, + __windowed: true, + }), + ), + canonicalWorkoutsInRange: vi.fn((windowed: { rows: unknown[] }) => [ + ...windowed.rows, + ]), })); import { @@ -636,8 +651,8 @@ describe("Activity Insight — locale", () => { */ describe("Activity Insight — own-history comparison", () => { it("routes the history rows through the canonical picker", async () => { - const { pickCanonicalWorkoutRows } = - await import("@/lib/measurements/pick-canonical-workout-rows"); + const { canonicalWorkoutsInRange } = + await import("@/lib/workouts/canonical-read"); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: null, locale: "en", @@ -650,20 +665,27 @@ describe("Activity Insight — own-history comparison", () => { await runWorkoutInsightGenerate({ userId: USER, workoutId: WORKOUT }, NOW); - expect(pickCanonicalWorkoutRows).toHaveBeenCalledTimes(1); + expect(canonicalWorkoutsInRange).toHaveBeenCalledTimes(1); // The user's own source ladder decides the winner, not a default. - expect(vi.mocked(pickCanonicalWorkoutRows).mock.calls[0][1]).toEqual({ + expect(vi.mocked(canonicalWorkoutsInRange).mock.calls[0][1]).toEqual({ ladder: ["APPLE_HEALTH"], }); }); - it("excludes the workout being described from its own baseline", async () => { + it("excludes the workout being described, twins included, from its own baseline", async () => { + const { canonicalWorkoutsInRange } = + await import("@/lib/workouts/canonical-read"); await runWorkoutInsightGenerate({ userId: USER, workoutId: WORKOUT }, NOW); const where = vi.mocked(prisma.workout.findMany).mock.calls[0][0] - ?.where as { userId: string; id: { not: string } }; + ?.where as { userId: string; id?: unknown }; expect(where.userId).toBe(USER); - expect(where.id).toEqual({ not: WORKOUT }); + // The row stays in the read so its whole duplicate cluster can be left + // out after the pick; dropping it in SQL left a twin behind. + expect(where.id).toBeUndefined(); + expect(vi.mocked(canonicalWorkoutsInRange).mock.calls[0][2]).toEqual({ + excludeClusterOf: WORKOUT, + }); }); }); diff --git a/src/lib/jobs/workout-insight-generate.ts b/src/lib/jobs/workout-insight-generate.ts index 699a68aa7..2f17773bb 100644 --- a/src/lib/jobs/workout-insight-generate.ts +++ b/src/lib/jobs/workout-insight-generate.ts @@ -44,7 +44,10 @@ import { WORKOUT_INSIGHT_PROMPT_VERSION, } from "@/lib/ai/prompts/workout-insight"; import { prisma } from "@/lib/db"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, +} from "@/lib/workouts/canonical-read"; import type { Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { finalizeStatusSummary } from "@/lib/insights/status-shared"; @@ -357,37 +360,37 @@ export async function runWorkoutInsightGenerate( whoopZoneDurations: parseWhoopZoneDurations(row.metadata), }); - // The current workout is excluded in SQL before canonicalization. The - // canonical picker then collapses cross-source twins before any median or - // prompt is built. - const historyRows = await prisma.workout.findMany({ - where: { - userId, - sportType: row.sportType, - id: { not: row.id }, - startedAt: { - gte: new Date( - row.startedAt.getTime() - OWN_HISTORY_LOOKBACK_DAYS * MS_PER_DAY, - ), - lt: row.startedAt, - }, + // The canonical sessions of the sport before this one, without this + // session's duplicate cluster: a twin a second source recorded a minute + // earlier is not the previous session. + const historyRows = await readWorkoutsAroundRange( + { + from: new Date( + row.startedAt.getTime() - OWN_HISTORY_LOOKBACK_DAYS * MS_PER_DAY, + ), + to: row.startedAt, }, - orderBy: { startedAt: "desc" }, - take: OWN_HISTORY_MAX_ROWS, - select: { - id: true, - source: true, - startedAt: true, - sportType: true, - durationSec: true, - avgHeartRate: true, - totalDistanceM: true, - totalEnergyKcal: true, - }, - }); - const history = pickCanonicalWorkoutRows( + (bounds) => + prisma.workout.findMany({ + where: { userId, sportType: row.sportType, startedAt: bounds }, + orderBy: [{ startedAt: "desc" }, { id: "asc" }], + take: OWN_HISTORY_MAX_ROWS, + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + durationSec: true, + avgHeartRate: true, + totalDistanceM: true, + totalEnergyKcal: true, + }, + }), + ); + const history = canonicalWorkoutsInRange( historyRows, profile?.sourcePriorityJson ?? null, + { excludeClusterOf: row.id }, ); const evidence = buildWorkoutInsightEvidence({ diff --git a/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts b/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts index 009643764..6379ca114 100644 --- a/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts +++ b/src/lib/measurements/__tests__/pick-canonical-workout-rows.property.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { canonicalWorkoutTrimps } from "@/lib/insights/strain-score"; +import { canonicalWorkoutTrimps } from "@/lib/workouts/canonical-read"; import { pickCanonicalWorkoutRows, diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index de26a85ef..cd46fbccb 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -133,8 +133,20 @@ function deduplicateWinningRows(rows: T[]): T[] { * within the window straddle a slot boundary, the clusters are exactly the * old slots: every pair inside one slot is less than five minutes apart, and * a cluster cannot reach past its slot without such a pair. + * + * Because a cluster is anchored on its first start, which cluster a workout + * lands in depends on the workouts before it. A reader that decides which row + * is canonical therefore reads through `readWorkoutsAroundRange` in + * `src/lib/workouts/canonical-read.ts`, which widens the read until the + * clusters of the requested range no longer depend on where the read began. */ -const DUPLICATE_WINDOW_MS = 5 * 60 * 1000; +export const WORKOUT_DUPLICATE_WINDOW_MS = 5 * 60 * 1000; +const DUPLICATE_WINDOW_MS = WORKOUT_DUPLICATE_WINDOW_MS; + +/** Rows of different groups never share a duplicate cluster. */ +export function workoutClusterGroup(row: WorkoutPickerRow): string { + return `${row.userId ?? ""}:${row.sportType}`; +} /** * The duplicate cluster of every row, aligned with `rows`. Rows of different @@ -151,7 +163,7 @@ export function workoutDuplicateClusterKeys( ): string[] { const groups = new Map(); rows.forEach((row, index) => { - const group = `${row.userId ?? ""}:${row.sportType}`; + const group = workoutClusterGroup(row); const list = groups.get(group); if (list) list.push(index); else groups.set(group, [index]); diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 818f6080c..1fec9ab70 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -336,7 +336,7 @@ export const workoutPaths: NonNullable = { tags: ["Measurements"], summary: "List workouts (v1.4.32)", description: - "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.", + "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and clusters workouts of one sport whose starts lie less than five minutes after the cluster's first start — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. A twin just outside `since` / `until` still decides which row inside the range is canonical, so the list agrees with `canonicalId` on the detail. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.", requestParams: { query: z.object({ limit: z.coerce.number().int().min(1).max(200).optional(), diff --git a/src/lib/workouts/__tests__/canonical-read.property.test.ts b/src/lib/workouts/__tests__/canonical-read.property.test.ts new file mode 100644 index 000000000..09f38c438 --- /dev/null +++ b/src/lib/workouts/__tests__/canonical-read.property.test.ts @@ -0,0 +1,240 @@ +/** + * The windowed read gives every surface the canonical workouts a read of the + * whole history would give, whatever range it asked for. + * + * The store is an in-memory array behind the same bounded-read callback the + * Prisma readers pass, so the property covers the reader's reach-back loop and + * the range filter, not a copy of them. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { assertAsyncProperty } from "../../../../tests/property/fast-check"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; + +import { + canonicalClusterOf, + canonicalWorkoutsInRange, + readWorkoutsAroundRange, + startsInRange, + type WorkoutStartBounds, + type WorkoutStartRange, +} from "../canonical-read"; + +type Source = "APPLE_HEALTH" | "WITHINGS" | "WHOOP" | "MANUAL"; + +interface Row { + id: string; + startedAt: Date; + durationSec: number; + sportType: string; + source: Source; + avgHeartRate: number | null; + maxHeartRate: number | null; + metadata: null; +} + +const MIN = 60_000; +const BASE = Date.UTC(2026, 9, 1, 9, 0, 0); + +function memoryStore(rows: readonly Row[]) { + let reads = 0; + const read = async (bounds: WorkoutStartBounds) => { + reads += 1; + return rows + .filter( + (row) => + (!bounds.gte || row.startedAt >= bounds.gte) && + (!bounds.lt || row.startedAt < bounds.lt), + ) + .sort( + (a, b) => + a.startedAt.getTime() - b.startedAt.getTime() || + a.id.localeCompare(b.id), + ); + }; + return { read, reads: () => reads }; +} + +function sortedAll(rows: readonly Row[]): Row[] { + return [...rows].sort( + (a, b) => + a.startedAt.getTime() - b.startedAt.getTime() || a.id.localeCompare(b.id), + ); +} + +/** What a read of the whole history says is canonical inside the range. */ +function oracle(rows: readonly Row[], range: WorkoutStartRange): string[] { + return pickCanonicalWorkoutRows(sortedAll(rows), null) + .filter((row) => startsInRange(range, row.startedAt)) + .map((row) => row.id) + .sort(); +} + +/** + * Workouts packed tightly enough that chains of starts under five minutes + * apart are common: the case where the cluster of a row depends on rows far + * before it. + */ +const rowsArb = fc + .array( + fc.record({ + gapMin: fc.integer({ min: 0, max: 9 }), + gapSec: fc.integer({ min: 0, max: 59 }), + sportType: fc.constantFrom("running", "running", "cycling"), + source: fc.constantFrom( + "APPLE_HEALTH", + "WITHINGS", + "WHOOP", + "MANUAL", + ), + durationMin: fc.integer({ min: 10, max: 60 }), + hr: fc.option(fc.integer({ min: 90, max: 170 }), { nil: null }), + }), + { minLength: 1, maxLength: 40 }, + ) + .map((specs) => { + let at = BASE; + return specs.map((spec, index): Row => { + at += spec.gapMin * MIN + spec.gapSec * 1000; + return { + id: `w${String(index).padStart(2, "0")}`, + startedAt: new Date(at), + durationSec: spec.durationMin * 60, + sportType: spec.sportType, + source: spec.source, + avgHeartRate: spec.hr, + maxHeartRate: null, + metadata: null, + }; + }); + }); + +const rangeArb = fc.record({ + fromOffsetMin: fc.option(fc.integer({ min: -10, max: 400 }), { nil: null }), + lengthMin: fc.option(fc.integer({ min: 0, max: 200 }), { nil: null }), + inclusiveEnd: fc.boolean(), +}); + +function toRange(spec: { + fromOffsetMin: number | null; + lengthMin: number | null; + inclusiveEnd: boolean; +}): WorkoutStartRange { + const from = + spec.fromOffsetMin === null + ? null + : new Date(BASE + spec.fromOffsetMin * MIN); + const to = + spec.lengthMin === null + ? null + : new Date((from?.getTime() ?? BASE) + spec.lengthMin * MIN); + return { from, to, inclusiveEnd: spec.inclusiveEnd }; +} + +describe("readWorkoutsAroundRange", () => { + it("resolves the range's canonical workouts as a whole-history read does", async () => { + await assertAsyncProperty( + fc.asyncProperty(rowsArb, rangeArb, async (rows, rangeSpec) => { + const range = toRange(rangeSpec); + const store = memoryStore(rows); + const windowed = await readWorkoutsAroundRange(range, store.read); + const got = canonicalWorkoutsInRange(windowed, null) + .map((row) => row.id) + .sort(); + expect(got).toEqual(oracle(rows, range)); + }), + 300, + ); + }); + + it("gives one workout the same cluster whichever range found it", async () => { + await assertAsyncProperty( + fc.asyncProperty(rowsArb, fc.nat(), async (rows, pick) => { + const target = rows[pick % rows.length]!; + const store = memoryStore(rows); + const point = await readWorkoutsAroundRange( + { from: target.startedAt, to: target.startedAt, inclusiveEnd: true }, + store.read, + ); + const whole = await readWorkoutsAroundRange( + { from: null, to: null }, + store.read, + ); + const fromPoint = canonicalClusterOf(point, target.id, null); + const fromWhole = canonicalClusterOf(whole, target.id, null); + expect(fromPoint?.members.map((row) => row.id)).toEqual( + fromWhole?.members.map((row) => row.id), + ); + expect(fromPoint?.canonical[0]?.id).toBe(fromWhole?.canonical[0]?.id); + }), + 300, + ); + }); + + it("depends on the history before the range, which the range alone does not show", async () => { + // Three runs four minutes apart. Read from 09:55 the 09:58 run anchors a + // cluster with 10:02, and 10:06 stands alone; read from 10:00 the 10:02 + // run anchors one with 10:06 and hides it behind the Apple row. The + // windowed read reaches back to 09:58 and draws the clusters a + // whole-history read draws. + const at = (h: number, m: number) => new Date(Date.UTC(2026, 9, 1, h, m)); + const rows: Row[] = [ + ["a", at(9, 58), "WITHINGS"], + ["b", at(10, 2), "APPLE_HEALTH"], + ["c", at(10, 6), "WITHINGS"], + ].map(([id, startedAt, source]) => ({ + id: id as string, + startedAt: startedAt as Date, + durationSec: 1800, + sportType: "running", + source: source as Source, + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + })); + const range = { from: at(10, 0), to: at(11, 0) }; + + // The range read alone drops the 10:06 run as a twin of 10:02. + const naive = pickCanonicalWorkoutRows( + rows.filter((row) => startsInRange(range, row.startedAt)), + null, + ).map((row) => row.id); + expect(naive).toEqual(["b"]); + + const windowed = await readWorkoutsAroundRange( + range, + memoryStore(rows).read, + ); + expect( + canonicalWorkoutsInRange(windowed, null).map((row) => row.id), + ).toEqual(["b", "c"]); + expect(oracle(rows, range)).toEqual(["b", "c"]); + }); + + it("leaves a workout's twins out of a baseline along with the workout", async () => { + const at = (m: number) => new Date(BASE + m * MIN); + const rows: Row[] = [ + { id: "old", startedAt: at(-600) }, + { id: "apple", startedAt: at(0) }, + { id: "withings", startedAt: at(1) }, + ].map((row, index) => ({ + ...row, + durationSec: 1800, + sportType: "running", + source: index === 2 ? "WITHINGS" : "APPLE_HEALTH", + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + })); + const windowed = await readWorkoutsAroundRange( + { from: at(-1000), to: null }, + memoryStore(rows).read, + ); + expect( + canonicalWorkoutsInRange(windowed, null, { + excludeClusterOf: "withings", + }).map((row) => row.id), + ).toEqual(["old"]); + }); +}); diff --git a/src/lib/workouts/batch-dedup.ts b/src/lib/workouts/batch-dedup.ts new file mode 100644 index 000000000..a7705123c --- /dev/null +++ b/src/lib/workouts/batch-dedup.ts @@ -0,0 +1,73 @@ +/** + * The write-time duplicate pick of `POST /api/workouts/batch`. + * + * The rows a batch is about to insert are clustered together with the stored + * workouts around them, read through the same windowed reader the list uses, + * so a cluster boundary falls where every read will later draw it. Clustering + * the batch alone could merge a row into a cluster the list then splits (a + * stored 09:58 run anchors a cluster with an incoming 10:02, which leaves an + * incoming 10:06 a session of its own) and drop a workout the list would have + * shown. + */ +import type { MeasurementSource, Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; + +import { + incomingWorkoutsToKeep, + readWorkoutsAroundRange, +} from "./canonical-read"; + +/** The indexes of the batch rows to write; the rest are duplicates. */ +export async function batchWorkoutIndicesToKeep( + userId: string, + prepared: ReadonlyArray<{ + index: number; + row: Prisma.WorkoutCreateManyInput; + }>, + priorityJson: unknown, +): Promise> { + if (prepared.length === 0) return new Set(); + const incoming = prepared.map((p) => ({ + index: p.index, + userId: p.row.userId, + sportType: p.row.sportType, + startedAt: p.row.startedAt as Date, + durationSec: p.row.durationSec, + // Zod's `measurementSourceEnum.optional().default("MANUAL")` guarantees + // `source` is set post-parse, but Prisma's `WorkoutCreateManyInput` types + // it optional. + source: (p.row.source ?? "MANUAL") as MeasurementSource, + avgHeartRate: p.row.avgHeartRate ?? null, + maxHeartRate: p.row.maxHeartRate ?? null, + metadata: p.row.metadata ?? null, + })); + const starts = incoming.map((row) => row.startedAt.getTime()); + const stored = await readWorkoutsAroundRange( + { + from: new Date(Math.min(...starts)), + to: new Date(Math.max(...starts)), + inclusiveEnd: true, + }, + (bounds) => + prisma.workout.findMany({ + where: { userId, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + userId: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + }, + }), + ); + return new Set( + incomingWorkoutsToKeep(stored, incoming, priorityJson).map( + (row) => row.index, + ), + ); +} diff --git a/src/lib/workouts/canonical-read.ts b/src/lib/workouts/canonical-read.ts new file mode 100644 index 000000000..22777a630 --- /dev/null +++ b/src/lib/workouts/canonical-read.ts @@ -0,0 +1,395 @@ +/** + * The read every surface that decides which workout is canonical goes through. + * + * `pickCanonicalWorkoutRows` clusters workouts of one sport by start time, + * anchored on each cluster's first start (see `workoutDuplicateClusterKeys`). + * That makes the cluster of a workout depend on the workouts around it: on + * the twin a few minutes after it, which may lie past the end of the range a + * surface asked for, and on the run of workouts before it, which decides where + * the anchors fall. A surface that handed the picker only the rows inside its + * own range got an answer that depended on the range: the list and the detail + * of one workout disagreed about which row was canonical, and 09:58, 10:02 and + * 10:06 were grouped one way by a read starting at 09:55 and another way by a + * read starting at 10:00. + * + * `readWorkoutsAroundRange` reads the range plus one duplicate window after its + * end, and before its start as far as it takes to reach, for every sport with a + * row in the range, a workout that starts a full window after the one before + * it. Such a workout is an anchor whatever comes before it, so from there on + * the clusters are the ones a read of the whole history would produce. The + * surfaces then pick over everything read and keep the rows that start inside + * their range: a workout is canonical, or not, the same way on every surface. + * + * A read callback may still carry a row cap as a runaway guard. A capped read + * that actually hits its cap has lost rows at one end and is outside this + * guarantee; the caps sit far above any real account's volume. + */ +import { + pickCanonicalWorkoutRows, + WORKOUT_DUPLICATE_WINDOW_MS, + workoutClusterGroup, + workoutDuplicateClusterKeys, + workoutSourceLadder, + type WorkoutPickerRow, +} from "@/lib/measurements/pick-canonical-workout-rows"; + +/** + * The columns a read needs for the canonical pick and nothing more: the + * session identity (`sportType`, `startedAt`, `durationSec`), the source the + * ladder ranks, and the id the windowed read orders by. + */ +export const WORKOUT_PICK_SELECT = { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, +} as const; + +/** A row the windowed read can order deterministically. */ +export type CanonicalReadRow = WorkoutPickerRow & { id: string }; + +/** + * The range of starts a surface asks about. `null` leaves that side open. + * `inclusiveEnd` makes `to` part of the range (`lte`), as the list's `until` + * and the doctor report's end are. + */ +export interface WorkoutStartRange { + from: Date | null; + to: Date | null; + inclusiveEnd?: boolean; +} + +/** The `startedAt` bounds one read callback must apply. */ +export interface WorkoutStartBounds { + gte?: Date; + lt?: Date; +} + +/** + * Reads the caller's workouts whose start lies inside `bounds`, with every + * other filter (user, sport) the caller applies. Called more than once when + * the read has to reach further back. + */ +export type WorkoutBoundedRead = ( + bounds: WorkoutStartBounds, +) => Promise; + +/** + * Rows read for a range, in `startedAt ASC, id ASC` order, together with the + * range they were read for. Only `readWorkoutsAroundRange` builds one, so a + * picker call below cannot be handed rows that were read without the margin. + */ +export interface WindowedWorkouts { + readonly rows: readonly T[]; + readonly range: WorkoutStartRange; + readonly __windowed: true; +} + +/** + * How many times the read reaches further back before it stops. Each step + * doubles the reach, so the last one covers more than a week of workouts + * every one of which started less than five minutes after the previous one. + */ +const MAX_BACKWARD_STEPS = 12; + +function compareRows(a: CanonicalReadRow, b: CanonicalReadRow): number { + const delta = a.startedAt.getTime() - b.startedAt.getTime(); + if (delta !== 0) return delta; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; +} + +/** + * The earliest start that is not yet known to be an anchor, among the sports + * that have a row at or after `from`. A sport's earliest row read is an anchor + * once the read reaches a full window before it (nothing of that sport starts + * inside that window, or the read would hold it). `null` when every sport is + * settled. + */ +function earliestUnsettledStart( + rows: readonly CanonicalReadRow[], + from: Date, + lowerMs: number, +): number | null { + const earliest = new Map(); + const relevant = new Set(); + for (const row of rows) { + const group = workoutClusterGroup(row); + const start = row.startedAt.getTime(); + const known = earliest.get(group); + if (known === undefined || start < known) earliest.set(group, start); + if (start >= from.getTime()) relevant.add(group); + } + let unsettled: number | null = null; + for (const group of relevant) { + const start = earliest.get(group)!; + if (start - WORKOUT_DUPLICATE_WINDOW_MS < lowerMs) { + unsettled = unsettled === null ? start : Math.min(unsettled, start); + } + } + return unsettled; +} + +export async function readWorkoutsAroundRange( + range: WorkoutStartRange, + read: WorkoutBoundedRead, +): Promise> { + const window = WORKOUT_DUPLICATE_WINDOW_MS; + // A cluster is never wider than one window from its anchor, and an anchor + // of the range starts no later than the range's end. + const upper = range.to ? new Date(range.to.getTime() + window) : undefined; + const from = range.from; + let lowerMs = from ? from.getTime() - window : null; + + const rows: T[] = [ + ...(await read({ + gte: lowerMs === null ? undefined : new Date(lowerMs), + lt: upper, + })), + ]; + + if (from && lowerMs !== null) { + for (let step = 0; step < MAX_BACKWARD_STEPS; step += 1) { + const unsettled = earliestUnsettledStart(rows, from, lowerMs); + if (unsettled === null) break; + const nextMs: number = Math.min( + unsettled - window, + lowerMs - window * 2 ** step, + ); + const earlier = await read({ + gte: new Date(nextMs), + lt: new Date(lowerMs), + }); + lowerMs = nextMs; + // Nothing between the new and the old bound: every unsettled sport now + // has a full window of nothing before its earliest row. + if (earlier.length === 0) break; + rows.push(...earlier); + } + } + + rows.sort(compareRows); + return { rows, range, __windowed: true }; +} + +/** Whether a start lies inside the range a surface asked about. */ +export function startsInRange(range: WorkoutStartRange, at: Date): boolean { + const t = at.getTime(); + if (range.from && t < range.from.getTime()) return false; + if (range.to) { + const end = range.to.getTime(); + if (range.inclusiveEnd ? t > end : t >= end) return false; + } + return true; +} + +/** The rows read that start inside the requested range. */ +export function rowsInRange( + windowed: WindowedWorkouts, +): T[] { + return windowed.rows.filter((row) => + startsInRange(windowed.range, row.startedAt), + ); +} + +function clusterKeyById( + windowed: WindowedWorkouts, +): Map { + const keys = workoutDuplicateClusterKeys(windowed.rows); + return new Map(windowed.rows.map((row, index) => [row.id, keys[index]!])); +} + +/** + * The canonical workouts that start inside the range, oldest first. + * + * `excludeClusterOf` leaves out the cluster of one workout: a baseline that + * compares a session against the others of its sport must drop the session's + * twins along with the session itself. Removing the row from the read instead + * left its twin behind as a second session, and moved the cluster anchors of + * whatever followed. + */ +export function canonicalWorkoutsInRange( + windowed: WindowedWorkouts, + priorityJson: unknown, + options: { excludeClusterOf?: string } = {}, +): T[] { + const picked = pickCanonicalWorkoutRows(windowed.rows, priorityJson).filter( + (row) => startsInRange(windowed.range, row.startedAt), + ); + if (!options.excludeClusterOf) return picked; + const keyById = clusterKeyById(windowed); + const excluded = keyById.get(options.excludeClusterOf); + if (excluded === undefined) return picked; + return picked.filter((row) => keyById.get(row.id) !== excluded); +} + +/** + * The duplicate cluster of one workout: its rows in start order, and the + * canonical ones among them, nearest to the workout's own start first. `null` + * when the workout is not among the rows read. + */ +export function canonicalClusterOf( + windowed: WindowedWorkouts, + workoutId: string, + priorityJson: unknown, +): { members: T[]; canonical: T[] } | null { + const self = windowed.rows.find((row) => row.id === workoutId); + if (!self) return null; + const keyById = clusterKeyById(windowed); + const key = keyById.get(workoutId); + const members = windowed.rows.filter((row) => keyById.get(row.id) === key); + const at = self.startedAt.getTime(); + const canonical = pickCanonicalWorkoutRows(members, priorityJson).sort( + (a, b) => + Number(b.id === workoutId) - Number(a.id === workoutId) || + Math.abs(a.startedAt.getTime() - at) - + Math.abs(b.startedAt.getTime() - at) || + compareRows(a, b), + ); + return { members, canonical }; +} + +/** + * The write-time pick of the batch route. The incoming rows are clustered + * together with the stored workouts around them, so a cluster boundary falls + * where every read will later draw it. An incoming row is dropped only when + * another incoming row of its cluster is the one kept: a row that loses to a + * stored workout is still written (the reads hide it), as before. + * + * Returns the incoming rows to keep, in their input order. + */ +export function incomingWorkoutsToKeep< + S extends CanonicalReadRow, + I extends WorkoutPickerRow, +>( + stored: WindowedWorkouts, + incoming: readonly I[], + priorityJson: unknown, +): I[] { + type Tagged = WorkoutPickerRow & { tag: number }; + // Stored rows first on an equal start, which is where a read that orders by + // start finds them relative to rows written after them. + const union: Tagged[] = [ + ...stored.rows.map((row) => ({ ...row, tag: -1 })), + ...incoming.map((row, index) => ({ ...row, tag: index })), + ].sort( + (a, b) => + a.startedAt.getTime() - b.startedAt.getTime() || + Number(a.tag >= 0) - Number(b.tag >= 0) || + a.tag - b.tag, + ); + const keys = workoutDuplicateClusterKeys(union); + const keyByTag = new Map(); + union.forEach((row, index) => { + if (row.tag >= 0) keyByTag.set(row.tag, keys[index]!); + }); + const keptTags = new Set( + pickCanonicalWorkoutRows(union, priorityJson) + .map((row) => row.tag) + .filter((tag) => tag >= 0), + ); + const clustersWithIncomingWinner = new Set( + [...keptTags].map((tag) => keyByTag.get(tag)!), + ); + return incoming.filter( + (_, index) => + keptTags.has(index) || + !clustersWithIncomingWinner.has(keyByTag.get(index)!), + ); +} + +/** + * One training impulse per workout SESSION, not per stored row. + * + * The same session can be stored more than once: the watch writes it to Apple + * Health and a second service (Strava, WHOOP, a ring) syncs it again, or + * HealthKit re-sends it under a new identifier. The workout list collapses + * those through `pickCanonicalWorkoutRows`; summing every row here counted the + * session twice in the day's TRIMP while the list showed it once. + * + * The picker chooses the row by source order, which says nothing about heart + * rate: the winning row can be one without a series while its dropped twin + * carries one. So the impulse of a slot is the sum over its picked rows, and + * only when that is zero the largest impulse among the slot's dropped rows. + * + * The picker keeps every row of a slot none of whose sources is on the + * ladder, so the picked rows of one slot can come from several sources. Only + * one source's rows count: the best-ranked on the ladder, a source off the + * ladder ranking below every listed one, and among sources off the ladder + * the one with the largest impulse. Rows of that one source still add up + * (two distinct sessions of the same sport in one duplicate cluster). + * + * `belongs` keeps the sessions of the scored range: the rows come from a + * windowed read that reaches past the day on both sides, and a session counts + * on the day its earliest picked row starts, the day the list shows it on. + * Returns the positive impulses, one per session. Pure; the strain + * engine's day TRIMP sums it, and the tests call it directly. + */ +export function canonicalWorkoutTrimps< + T extends WorkoutPickerRow & { id: string }, +>( + rows: readonly T[], + priorityJson: unknown, + trimpOf: (row: T) => number, + belongs: (startedAt: Date) => boolean = () => true, +): number[] { + // By id: the picker may hand back a copy of a row (it fills a missing heart + // rate from a WHOOP twin). + const picked = new Set( + pickCanonicalWorkoutRows(rows, priorityJson).map((row) => row.id), + ); + const ladder = workoutSourceLadder(priorityJson); + const rankOf = (source: string) => { + const index = ladder.indexOf(source as (typeof ladder)[number]); + return index === -1 ? ladder.length : index; + }; + const slots = new Map< + string, + { + picked: Map; + dropped: number; + firstPickedAt: Date | null; + } + >(); + const keys = workoutDuplicateClusterKeys(rows); + for (const [index, row] of rows.entries()) { + const slot = slots.get(keys[index]!) ?? { + picked: new Map(), + dropped: 0, + firstPickedAt: null, + }; + if ( + picked.has(row.id) && + (slot.firstPickedAt === null || row.startedAt < slot.firstPickedAt) + ) { + slot.firstPickedAt = row.startedAt; + } + const trimp = trimpOf(row); + if (trimp > 0) { + if (picked.has(row.id)) { + slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + trimp); + } else { + slot.dropped = Math.max(slot.dropped, trimp); + } + } + slots.set(keys[index]!, slot); + } + const out: number[] = []; + for (const slot of slots.values()) { + if (slot.firstPickedAt === null || !belongs(slot.firstPickedAt)) continue; + let best: { rank: number; trimp: number } | null = null; + for (const [source, trimp] of slot.picked) { + const rank = rankOf(source); + if ( + best === null || + rank < best.rank || + (rank === best.rank && trimp > best.trimp) + ) { + best = { rank, trimp }; + } + } + const trimp = best !== null ? best.trimp : slot.dropped; + if (trimp > 0) out.push(trimp); + } + return out; +} diff --git a/src/lib/workouts/list-read.ts b/src/lib/workouts/list-read.ts index c61e11d9c..ff4f896d0 100644 --- a/src/lib/workouts/list-read.ts +++ b/src/lib/workouts/list-read.ts @@ -18,7 +18,11 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, + rowsInRange, +} from "@/lib/workouts/canonical-read"; import { cached, caches } from "@/lib/cache/server-cache"; import { workoutSportTypeEnum } from "@/lib/validations/workout"; import { isoDateTime } from "@/lib/validations/datetime"; @@ -131,62 +135,63 @@ export async function buildWorkoutsProjection( ): Promise { const { since, until, sportType } = params; - const where: Record = { userId }; - if (since || until) { - const range: Record = {}; - if (since) { - const d = new Date(since); - if (!Number.isNaN(d.getTime())) range.gte = d; - } - if (until) { - const d = new Date(until); - if (!Number.isNaN(d.getTime())) range.lte = d; - } - if (Object.keys(range).length > 0) { - where.startedAt = range; - } - } - if (sportType) { - where.sportType = sportType; - } + const parse = (value: string | null): Date | null => { + if (!value) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; + }; + const range = { + from: parse(since), + to: parse(until), + inclusiveEnd: true, + }; // Independent reads — the sourcePriorityJson lookup doesn't depend on the // row read, so run them concurrently rather than paying two sequential - // round trips. - const [rows, userRow] = await Promise.all([ - prisma.workout.findMany({ - where, - orderBy: [{ startedAt: "desc" }, { id: "asc" }], - select: { - id: true, - source: true, - externalId: true, - sportType: true, - startedAt: true, - endedAt: true, - durationSec: true, - totalDistanceM: true, - totalEnergyKcal: true, - avgHeartRate: true, - maxHeartRate: true, - metadata: true, - createdAt: true, - route: { select: { id: true } }, - samples: { select: { sampleCount: true } }, - }, - }), + // round trips. The rows come through the windowed reader: a twin just past + // `until`, or a cluster that began just before `since`, decides which row + // of the range is canonical exactly as it does for the detail route. + const [around, userRow] = await Promise.all([ + readWorkoutsAroundRange(range, (bounds) => + prisma.workout.findMany({ + where: { + userId, + startedAt: bounds, + ...(sportType ? { sportType } : {}), + }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + select: { + id: true, + source: true, + externalId: true, + sportType: true, + startedAt: true, + endedAt: true, + durationSec: true, + totalDistanceM: true, + totalEnergyKcal: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + createdAt: true, + route: { select: { id: true } }, + samples: { select: { sampleCount: true } }, + }, + }), + ), prisma.user.findUnique({ where: { id: userId }, select: { sourcePriorityJson: true }, }), ]); - const canonical = pickCanonicalWorkoutRows( - rows, + const canonical: CanonicalWorkoutRow[] = canonicalWorkoutsInRange( + around, userRow?.sourcePriorityJson ?? null, ); + const rawCount = rowsInRange(around).length; - // `pickCanonicalWorkoutRows` re-sorts its output to `startedAt ASC` (its + // The canonical pick comes back in `startedAt ASC` order (its // documented contract for the rollup callers). This surface is a // most-recent-first list, so the DB `orderBy startedAt desc` must be // re-established AFTER the pick — otherwise `offset`/`limit` slice the @@ -197,7 +202,7 @@ export async function buildWorkoutsProjection( return a.id.localeCompare(b.id); }); - return { canonical, rawCount: rows.length }; + return { canonical, rawCount }; } export function sliceWorkoutsProjection( diff --git a/src/lib/workouts/sport-context.ts b/src/lib/workouts/sport-context.ts index 9e16828d0..f97f69e4d 100644 --- a/src/lib/workouts/sport-context.ts +++ b/src/lib/workouts/sport-context.ts @@ -18,7 +18,10 @@ import { type CoachHistoryReach, } from "@/lib/ai/coach/history-reach"; import { prisma } from "@/lib/db"; -import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { + canonicalWorkoutsInRange, + readWorkoutsAroundRange, +} from "@/lib/workouts/canonical-read"; /** Own-history lookback for the sport-average comparison line. */ const SPORT_CONTEXT_LOOKBACK_DAYS = 180; @@ -47,28 +50,32 @@ export async function buildSportContext( const since = new Date( Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, ); - const rows = await prisma.workout.findMany({ - // `userId` stays in the where clause — the universal tenancy narrow. The - // selected workout is not part of the baseline it is compared against. - where: { - userId, - sportType, - startedAt: { gte: since }, - ...(excludeWorkoutId ? { id: { not: excludeWorkoutId } } : {}), - }, - orderBy: [{ startedAt: "asc" }, { id: "asc" }], - take: SPORT_CONTEXT_MAX_ROWS, - select: { - id: true, - source: true, - startedAt: true, - sportType: true, - durationSec: true, - totalDistanceM: true, - avgHeartRate: true, - }, + // `userId` stays in the where clause — the universal tenancy narrow. The + // selected workout is not part of the baseline it is compared against, and + // neither are its twins: the selected row stays in the read so its whole + // duplicate cluster can be left out after the pick. Dropping only the row in + // SQL left a twin from a second source behind as a session of its own. + const rows = await readWorkoutsAroundRange( + { from: since, to: null }, + (bounds) => + prisma.workout.findMany({ + where: { userId, sportType, startedAt: bounds }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + take: SPORT_CONTEXT_MAX_ROWS, + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + durationSec: true, + totalDistanceM: true, + avgHeartRate: true, + }, + }), + ); + const canonical = canonicalWorkoutsInRange(rows, sourcePriorityJson, { + excludeClusterOf: excludeWorkoutId, }); - const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson); if (canonical.length === 0) return null; let durationSum = 0; diff --git a/tests/integration/routes/workouts-canonical-window.test.ts b/tests/integration/routes/workouts-canonical-window.test.ts new file mode 100644 index 000000000..7dfdbd0cf --- /dev/null +++ b/tests/integration/routes/workouts-canonical-window.test.ts @@ -0,0 +1,215 @@ +/** + * The workout list and the workout detail resolve a duplicate cluster the same + * way, whatever range either of them read. + * + * - Two copies of one run thirty seconds apart on either side of a + * five-minute clock boundary: the list shows the Apple Health row, and the + * detail of the Withings copy names that row as `canonicalId`. The detail + * used to read only the clock slot of the requested row, found no twin, + * and declared the hidden copy canonical. + * - Runs at 09:58 (Withings), 10:02 (Apple Health) and 10:06 (Withings): a + * whole-history read pairs 09:58 with 10:02 and leaves 10:06 alone. A list + * filtered from 10:00 shows 10:02 and 10:06, the detail of every row agrees + * with that, and the session before 10:06 is 10:02. + * - The batch write clusters what it is about to insert together with the + * stored workouts around it: with 09:58 stored, a batch of 10:02 (Apple + * Health) and 10:06 (entered by hand) keeps both, where clustering the + * batch alone dropped 10:06 as a twin of 10:02 although every read shows + * it as a session of its own. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { GET as getDetail } from "@/app/api/workouts/[id]/route"; +import { GET as getList } from "@/app/api/workouts/route"; +import { POST as postBatch } from "@/app/api/workouts/batch/route"; + +import { callRoute, type RouteAuth } from "../../kit/call-route"; +import { makeSession, makeUser, makeWorkout } from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); + +beforeEach(() => truncateAllTables(prisma)); + +const DAY_MS = 24 * 60 * 60 * 1000; +/** A day ten days back, so the list's default window always holds it. */ +const DAY = new Date(Date.now() - 10 * DAY_MS).toISOString().slice(0, 10); +/** An instant on that day, `HH:MM:SS` in UTC. */ +const at = (time: string) => `${DAY}T${time}.000Z`; + +interface ListBody { + workouts: Array<{ id: string }>; +} +interface DetailBody { + id: string; + canonicalId: string; + previousWorkoutId: string | null; +} + +async function run( + user: Pick, + startedAt: string, + source: "APPLE_HEALTH" | "WITHINGS", +) { + return makeWorkout(user, { + startedAt: new Date(startedAt), + endedAt: new Date(new Date(startedAt).getTime() + 30 * 60 * 1000), + durationSec: 30 * 60, + source, + externalId: `${source}-${startedAt}`, + }); +} + +async function list(auth: RouteAuth, query: Record = {}) { + const res = await callRoute(getList, { + method: "GET", + route: "/api/workouts", + query, + auth, + }); + expect(res.status).toBe(200); + return res.data!.workouts.map((w) => w.id); +} + +async function detail(auth: RouteAuth, id: string) { + const res = await callRoute(getDetail, { + method: "GET", + route: "/api/workouts/{id}", + params: { id }, + query: { compact: "1" }, + auth, + }); + expect(res.status).toBe(200); + return res.data!; +} + +describe("list and detail agree on the canonical workout", () => { + it("resolves a twin across a clock-slot boundary to the row the list shows", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + const apple = await run(user, at("08:04:50"), "APPLE_HEALTH"); + const withings = await run(user, at("08:05:20"), "WITHINGS"); + + expect(await list(auth)).toEqual([apple.id]); + expect((await detail(auth, apple.id)).canonicalId).toBe(apple.id); + // The deep link to the hidden copy lands on the row the list shows. + expect((await detail(auth, withings.id)).canonicalId).toBe(apple.id); + }); + + it("draws the clusters of a filtered range as a read of the whole history does", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + const early = await run(user, at("09:58:00"), "WITHINGS"); + const middle = await run(user, at("10:02:00"), "APPLE_HEALTH"); + const late = await run(user, at("10:06:00"), "WITHINGS"); + + expect(await list(auth)).toEqual([late.id, middle.id]); + // Filtered from 10:00 the list no longer holds the 09:58 run, and still + // draws the clusters the whole history draws: 10:06 is a session of its + // own, not a twin of 10:02. + expect(await list(auth, { since: at("10:00:00") })).toEqual([ + late.id, + middle.id, + ]); + // Up to 10:04 the list must still hide 10:02's twin from 09:58 and show + // 10:02, though the read stops before 10:06. + expect( + await list(auth, { + since: at("09:59:00"), + until: at("10:04:00"), + }), + ).toEqual([middle.id]); + + expect((await detail(auth, early.id)).canonicalId).toBe(middle.id); + expect((await detail(auth, middle.id)).canonicalId).toBe(middle.id); + const lateDetail = await detail(auth, late.id); + expect(lateDetail.canonicalId).toBe(late.id); + expect(lateDetail.previousWorkoutId).toBe(middle.id); + // The session before the 10:02 run is none: 09:58 is its own twin. + expect((await detail(auth, middle.id)).previousWorkoutId).toBeNull(); + }); +}); + +describe("the batch write draws clusters where the reads draw them", () => { + it("keeps a workout the reads show as a session of its own", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + await run(user, at("09:58:00"), "WITHINGS"); + + const res = await callRoute<{ + entries: Array<{ index: number; status: string }>; + }>(postBatch, { + method: "POST", + route: "/api/workouts/batch", + body: { + workouts: [ + { + sportType: "running", + startedAt: at("10:02:00"), + endedAt: at("10:32:00"), + source: "APPLE_HEALTH", + externalId: "apple-1002", + }, + { + sportType: "running", + startedAt: at("10:06:00"), + endedAt: at("10:36:00"), + source: "MANUAL", + externalId: "manual-1006", + }, + ], + }, + auth, + }); + + expect(res.status).toBe(200); + expect(res.data!.entries.map((e) => e.status)).toEqual([ + "inserted", + "inserted", + ]); + const stored = await prisma.workout.findMany({ + where: { userId: user.id }, + orderBy: { startedAt: "asc" }, + select: { id: true, externalId: true }, + }); + expect(await list(auth)).toEqual([stored[2]!.id, stored[1]!.id]); + }); + + it("still drops the twin a batch carries of its own winner", async () => { + const user = await makeUser(); + const auth = await makeSession(user); + + const res = await callRoute<{ + entries: Array<{ index: number; status: string }>; + }>(postBatch, { + method: "POST", + route: "/api/workouts/batch", + body: { + workouts: [ + { + sportType: "running", + startedAt: at("08:04:50"), + endedAt: at("08:34:50"), + source: "APPLE_HEALTH", + externalId: "apple-0804", + }, + { + sportType: "running", + startedAt: at("08:05:20"), + endedAt: at("08:35:20"), + source: "MANUAL", + externalId: "manual-0805", + }, + ], + }, + auth, + }); + + expect(res.status).toBe(200); + expect(res.data!.entries.map((e) => e.status)).toEqual([ + "inserted", + "duplicate", + ]); + }); +});