mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Status cards and derived-score sheets read one way: they state a verdict and
end there. The metric pages already carried the hand-off; the score sheets
passed neither an opener nor a scope, so they painted no action at all.
- Score sheets (sleep / readiness / recovery / stress / strain) gain the same
auto-sent opener the metric pages use, each scoped to the inputs that drive
the composite rather than to a block it has no equivalent of.
- Workout detail's coach launch carries the workout itself. The chat request
gains an optional workoutId; on the FIRST turn the route narrows by
{ id, userId } and pins ONE bounded evidence section onto the snapshot.
Later turns reuse the pinned snapshot, so per-turn work stays constant in
conversation length.
- /coach?workout=<id> covers the full-page and maximize paths.
The workout block deliberately does not take the fenced transport the stored-
document path takes: a workout row is the user's own sensor record, and the
projection admits no free text. That is enforced, not assumed — sport_type is
a TEXT column, so it is re-asserted against the closed sport union, metadata
is excluded apart from its numeric zone durations read through a Zod slice,
and a recursive guard throws on any non-numeric leaf.
Own-history sport averages move to src/lib/workouts/sport-context.ts so the
narrative and the detail page cannot quote different figures.
79 lines
2.6 KiB
TypeScript
79 lines
2.6 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 { prisma } from "@/lib/db";
|
|
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
|
|
|
/** Own-history lookback for the sport-average comparison line. */
|
|
export const SPORT_CONTEXT_LOOKBACK_DAYS = 180;
|
|
/** Slim-row cap for the sport-average read. */
|
|
export 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,
|
|
): Promise<WorkoutSportContext | null> {
|
|
const since = new Date(
|
|
Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
|
|
);
|
|
const rows = await prisma.workout.findMany({
|
|
// `userId` stays in the where clause — the universal tenancy narrow.
|
|
where: { userId, sportType, startedAt: { gte: since } },
|
|
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 = pickCanonicalWorkoutRows(rows, sourcePriorityJson);
|
|
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,
|
|
};
|
|
}
|