Files
HealthLog/src/lib/workouts/sport-context.ts
T
Marc-André Bombeck 466dfb3785 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.
2026-10-22 18:21:02 +02:00

104 lines
3.4 KiB
TypeScript

/**
* Own-history comparison for a single sport.
*
* The user's own recent average for a sport, rendered as one muted
* comparison line on the workout-detail page and as the comparison leg of
* the Coach's per-workout evidence block. Cross-source twins are collapsed
* through `pickCanonicalWorkoutRows()` first so a run recorded by two
* paired watches counts once. Comparisons are to the user's own history
* only (non-diagnostic standard) — never to a population band.
*
* Lifted out of `GET /api/workouts/{id}` so the Coach's workout evidence
* block reads the SAME figures the detail page shows. Two surfaces quoting
* different averages for one sport is the drift this move removes.
*/
import {
UNBOUNDED_REACH,
fitsReach,
type CoachHistoryReach,
} from "@/lib/ai/coach/history-reach";
import { prisma } from "@/lib/db";
import {
canonicalWorkoutsInRange,
readWorkoutsAroundRange,
} from "@/lib/workouts/canonical-read";
/** Own-history lookback for the sport-average comparison line. */
const SPORT_CONTEXT_LOOKBACK_DAYS = 180;
/** Slim-row cap for the sport-average read. */
const SPORT_CONTEXT_MAX_ROWS = 500;
export interface WorkoutSportContext {
count: number;
avgDurationSec: number;
avgDistanceM: number | null;
avgAvgHr: number | null;
}
export async function buildSportContext(
userId: string,
sportType: string,
sourcePriorityJson: unknown,
excludeWorkoutId?: string,
/**
* The Coach's lookback limit. The comparison is a fixed 180 days; under a
* shorter limit it is left out rather than drawn from fewer sessions.
*/
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<WorkoutSportContext | null> {
if (!fitsReach(SPORT_CONTEXT_LOOKBACK_DAYS, reach)) return null;
const since = new Date(
Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
);
// `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,
});
if (canonical.length === 0) return null;
let durationSum = 0;
let distanceSum = 0;
let distanceCount = 0;
let hrSum = 0;
let hrCount = 0;
for (const r of canonical) {
durationSum += r.durationSec;
if (r.totalDistanceM != null) {
distanceSum += r.totalDistanceM;
distanceCount += 1;
}
if (r.avgHeartRate != null) {
hrSum += r.avgHeartRate;
hrCount += 1;
}
}
return {
count: canonical.length,
avgDurationSec: Math.round(durationSum / canonical.length),
avgDistanceM: distanceCount > 0 ? distanceSum / distanceCount : null,
avgAvgHr: hrCount > 0 ? Math.round(hrSum / hrCount) : null,
};
}