mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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.
104 lines
3.4 KiB
TypeScript
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,
|
|
};
|
|
}
|