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}`;