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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 21:12:23 +02:00
1 parent e1b70537cc
commit 30248efaf1
7 files changed
+277 -35

No files matched your search

@@ -52,6 +52,12 @@ const IDENTITY_OWNER: Record<string, string> = {
// 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",
};
/**
+27 -8
View File
@@ -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<SectionPart> {
/* ─── workouts, cycle ─────────────────────────────────────────────────────── */
async function workoutsPart(frame: DayFrame): Promise<SectionPart> {
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) =>
@@ -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", () => {
@@ -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);
});
});
+27 -11
View File
@@ -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<AggregatedFeatures["workouts"] | undefined> {
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) => {
+90 -14
View File
@@ -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<string, { picked: number; dropped: number }>();
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.
@@ -127,6 +127,15 @@ function deduplicateWinningRows<T extends WorkoutPickerRow>(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}`;