From 30248efaf1081e159e7729ea3fba3c32ff9d851a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:12:23 +0200 Subject: [PATCH] fix(workouts): count a session synced twice once in strain, briefing and day view The training impulse summed every stored workout row of the day, and the briefing's workout tally and the day view's workout section listed every row, while the workout list collapses a session recorded by the watch and synced again through a second service. All three now run the same cross-source picker. The strain engine keeps the heart-rate series of a dropped twin when the picked row has none, so the collapse never turns a scored session into an unscored one. --- .../workout-canonical-identity-guard.test.ts | 6 + src/lib/day/records.ts | 35 ++++-- src/lib/insights/__tests__/features.test.ts | 24 ++++ .../insights/__tests__/strain-score.test.ts | 96 +++++++++++++++- src/lib/insights/feature-blocks.ts | 38 +++++-- src/lib/insights/strain-score.ts | 104 +++++++++++++++--- .../pick-canonical-workout-rows.ts | 9 ++ 7 files changed, 277 insertions(+), 35 deletions(-) diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index 416faa7e5..dfbc5cfbe 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -52,6 +52,12 @@ const IDENTITY_OWNER: Record = { // 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 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. + "lib/insights/strain-score.ts": "lib/insights/strain-score.ts", + "lib/insights/feature-blocks.ts": "lib/insights/feature-blocks.ts", + "lib/day/records.ts": "lib/day/records.ts", }; /** diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index b3581cac9..4d1cd2bf4 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -42,6 +42,7 @@ 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"; /** Everything a section reader needs about the day. */ export interface DayFrame { @@ -904,14 +905,32 @@ async function assessmentsPart(frame: DayFrame): Promise { /* ─── workouts, cycle ─────────────────────────────────────────────────────── */ async function workoutsPart(frame: DayFrame): Promise { - const rows = await prisma.workout.findMany({ - where: { - userId: frame.userId, - startedAt: { gte: frame.dayStart, lt: frame.dayEnd }, - }, - select: { id: true, sportType: true, startedAt: true, durationSec: true }, - orderBy: { startedAt: "asc" }, - }); + 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" }], + }), + 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( + stored, + account?.sourcePriorityJson ?? null, + ); return { running: [], events: rows.map((row) => diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 82480c191..f38e096f0 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -557,6 +557,30 @@ describe("extractFeatures — v1.22 workouts block", () => { expect(f.workouts?.last7.totalDistanceKm).toBe(5); expect(f.workouts?.latest?.sportType).toBe("running"); }); + + it("counts a session synced by two services once", async () => { + const startedAt = new Date(Date.now() - 1 * DAY_MS); + prismaMock.workout.findMany.mockResolvedValue([ + { + source: "APPLE_HEALTH", + sportType: "running", + startedAt, + durationSec: 1800, + totalDistanceM: 5000, + }, + { + source: "STRAVA", + sportType: "running", + startedAt: new Date(startedAt.getTime() + 30_000), + durationSec: 1795, + totalDistanceM: 5010, + }, + ]); + const f = await extractFeatures("user-1", false); + expect(f.workouts?.last7.count).toBe(1); + expect(f.workouts?.last7.totalDurationMin).toBe(30); + expect(f.workouts?.last7.totalDistanceKm).toBe(5); + }); }); describe("extractFeatures — v1.22 integration blocks are briefing-scoped", () => { diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index 6ac225af0..fcf7173fb 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -32,6 +32,7 @@ import { percentile, resolvePersonalReference, persistStrainScore, + canonicalWorkoutTrimps, STRAIN_SCORE_EXTERNAL_ID_PREFIX, STRAIN_TRIMP_REFERENCE, STRAIN_MIN_TRAINING_DAYS, @@ -152,7 +153,14 @@ describe("saturateToScore", () => { function makePrisma(opts: { energy?: number[]; - workouts?: Array<{ id: string; samples: { samples: unknown } | null }>; + workouts?: Array<{ + id: string; + samples: { samples: unknown } | null; + source?: string; + sportType?: string; + startedAt?: Date; + durationSec?: number; + }>; rhr?: number | null; gender?: string; dateOfBirth?: Date | null; @@ -172,7 +180,17 @@ function makePrisma(opts: { const measurementFindFirst = vi .fn() .mockResolvedValue(opts.rhr == null ? null : { value: opts.rhr }); - const workoutFindMany = vi.fn().mockResolvedValue(opts.workouts ?? []); + // Distinct sessions by default: each fixture workout starts an hour after + // the previous one, so the cross-source picker keeps every row. + const workoutFindMany = vi.fn().mockResolvedValue( + (opts.workouts ?? []).map((w, i) => ({ + source: "APPLE_HEALTH", + sportType: "RUNNING", + startedAt: new Date(Date.UTC(2026, 5, 1, 6 + i)), + durationSec: 1800, + ...w, + })), + ); const upsert = vi.fn().mockResolvedValue({}); const findUnique = vi.fn().mockResolvedValue({ dateOfBirth: @@ -519,3 +537,77 @@ describe("persistStrainScore — personal anchor + cache", () => { expect(cacheArg.create.refPersonal).toBeLessThan(80); }); }); + +describe("canonicalWorkoutTrimps — one impulse per session", () => { + const start = new Date("2026-06-01T07:00:00.000Z"); + const row = ( + id: string, + source: string, + trimp: number, + offsetMin = 0, + sportType = "RUNNING", + ) => ({ + id, + source: source as "APPLE_HEALTH", + sportType, + startedAt: new Date(start.getTime() + offsetMin * 60_000), + durationSec: 1800, + trimp, + }); + + it("counts a session synced by two services once", () => { + const rows = [row("a", "APPLE_HEALTH", 40), row("s", "STRAVA", 38, 1)]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([40]); + }); + + it("takes the dropped twin's impulse when the picked row has no series", () => { + // Apple ranks first on the default ladder; its copy carries no HR here. + const rows = [row("a", "APPLE_HEALTH", 0), row("w", "WHOOP", 55, 2)]; + expect(canonicalWorkoutTrimps(rows, null, (r) => r.trimp)).toEqual([55]); + }); + + it("keeps two distinct sessions apart", () => { + const rows = [ + row("a", "APPLE_HEALTH", 40), + row("b", "APPLE_HEALTH", 20, 120), + row("c", "APPLE_HEALTH", 15, 1, "CYCLING"), + ]; + expect( + canonicalWorkoutTrimps(rows, null, (r) => r.trimp).sort((x, y) => x - y), + ).toEqual([15, 20, 40]); + }); + + it("is empty when nothing carries an impulse", () => { + expect( + canonicalWorkoutTrimps([row("a", "APPLE_HEALTH", 0)], null, () => 0), + ).toEqual([]); + }); +}); + +describe("persistStrainScore — duplicate sessions", () => { + it("scores a session stored twice the same as the session stored once", async () => { + const series = { + samples: [ + { t: "2026-06-01T08:00:00.000Z", hr: 150 }, + { t: "2026-06-01T08:30:00.000Z", hr: 155 }, + { t: "2026-06-01T09:00:00.000Z", hr: 150 }, + ], + }; + const startedAt = new Date("2026-06-01T08:00:00.000Z"); + const once = makePrisma({ + rhr: 50, + workouts: [{ id: "w1", samples: series, startedAt }], + }); + const twice = makePrisma({ + rhr: 50, + workouts: [ + { id: "w1", samples: series, startedAt }, + { id: "w2", samples: series, startedAt, source: "STRAVA" }, + ], + }); + const a = await persistStrainScore(once.prisma, "user-1", NOW); + const b = await persistStrainScore(twice.prisma, "user-1", NOW); + expect(a.score).toBeGreaterThan(0); + expect(b.score).toBe(a.score); + }); +}); diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 8e5af6078..5e3916870 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -28,6 +28,7 @@ import { readRollupBuckets, ROLLUP_FOLD_WINDOW_MS, } from "@/lib/rollups/measurement-rollups"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; import type { AggregatedFeatures, @@ -535,17 +536,32 @@ export async function readWorkoutsBlock( now: number, ): Promise { const since = new Date(now - WORKOUT_WINDOW_DAYS * MS_PER_DAY); - const rows = await prisma.workout.findMany({ - where: { userId, startedAt: { gte: since } }, - orderBy: { startedAt: "desc" }, - take: 2000, - select: { - sportType: true, - startedAt: true, - durationSec: true, - totalDistanceM: true, - }, - }); + 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, + }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + ]); + // One session synced by two services (or re-sent by HealthKit) is one + // 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( + stored, + account?.sourcePriorityJson ?? null, + ).reverse(); if (rows.length === 0) return undefined; const tally = (windowDays: number) => { diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 3cfee08f0..52e02f51a 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -72,6 +72,11 @@ import { upsertScoreRow, } from "@/lib/insights/score-row"; import { shiftDateKey } from "@/lib/tz/format"; +import { + pickCanonicalWorkoutRows, + workoutDuplicateSlotKey, + type WorkoutPickerRow, +} from "@/lib/measurements/pick-canonical-workout-rows"; /** The per-day idempotency-key prefix for a stored Strain score row. */ export const STRAIN_SCORE_EXTERNAL_ID_PREFIX = "strain:"; @@ -413,12 +418,28 @@ export async function computeStrainScore( }); const dayActiveEnergy = energyRows.reduce((s, r) => s + r.value, 0); - // The day's workouts + their HR series. - const workouts = await prisma.workout.findMany({ - where: { userId, startedAt: { gte: dayStart, lt: dayEnd } }, - select: { id: true, samples: { select: { samples: true } } }, - take: 100, - }); + // The day's workouts + their HR series, with the session identity the + // 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, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + ]); const hrMax = profile.ageYears != null ? tanakaHrMax(profile.ageYears) : null; const canTrimp = hrMax != null && hrRest != null && hrMax > hrRest; @@ -426,15 +447,24 @@ export async function computeStrainScore( let dayTrimp = 0; let workoutsWithSeries = 0; if (canTrimp) { - for (const w of workouts) { + const trimpOf = (w: (typeof workouts)[number]): number => { const raw = w.samples?.samples; - if (!Array.isArray(raw)) continue; - const series = raw as unknown as HrSample[]; - const trimp = banisterTrimp(series, hrRest!, hrMax!, profile.sex); - if (trimp > 0) { - dayTrimp += trimp; - workoutsWithSeries += 1; - } + if (!Array.isArray(raw)) return 0; + return banisterTrimp( + raw as unknown as HrSample[], + hrRest!, + hrMax!, + profile.sex, + ); + }; + const sessions = canonicalWorkoutTrimps( + workouts, + account?.sourcePriorityJson ?? null, + trimpOf, + ); + for (const trimp of sessions) { + dayTrimp += trimp; + workoutsWithSeries += 1; } } @@ -499,6 +529,52 @@ export async function computeStrainScore( }; } +/** + * 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. + * 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 slots = new Map(); + for (const row of rows) { + const key = workoutDuplicateSlotKey(row); + const slot = slots.get(key) ?? { picked: 0, dropped: 0 }; + const trimp = trimpOf(row); + if (trimp > 0) { + if (picked.has(row.id)) slot.picked += trimp; + else slot.dropped = Math.max(slot.dropped, trimp); + } + slots.set(key, slot); + } + const out: number[] = []; + for (const slot of slots.values()) { + const trimp = slot.picked > 0 ? slot.picked : 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/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index 50fda8fdf..d426cb1c0 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -127,6 +127,15 @@ function deduplicateWinningRows(rows: T[]): T[] { */ const BUCKET_SLOT_MS = 5 * 60 * 1000; +/** + * The cross-source duplicate slot a workout falls in. Exported for the one + * reader that has to look past the picked row: the strain engine, whose winner + * may carry no heart-rate series while its dropped twin does. + */ +export function workoutDuplicateSlotKey(row: WorkoutPickerRow): string { + return bucketKey(row); +} + function bucketKey(row: WorkoutPickerRow): string { const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS); return `${row.userId ?? ""}:${slot}:${row.sportType}`;