Files
HealthLog/src/lib/doctor-report/workouts.ts
T
Marc-André Bombeck 9e2a0681b2 feat(doctor-report): add sleep, HRV, steps, workouts and the medication effect
The standard report template now carries sleep duration, heart-rate
variability as two labelled rows (SDNN and RMSSD), daily steps, a
per-sport workout summary and the change around each medication start.
Each still rides its module, and the template is still only applied by
the button that names it; saved selections and share links are
untouched.

Two structured leaves join the catalogue: WORKOUTS (activity group,
workouts module) and MEDICATION_EFFECT (medications group). The workout
block reads narrow rows through the canonical-workout picker and sums
them per sport. The effect block reuses the efficacy loader with the
report's end as its horizon, carries a target only when that target's
own measurement leaf is admitted, and is capped at eight medications.

In the PDF the wearable series chart at half width, two to a row, in
the units the table prints; the workout and effect blocks are compact
tables with short notes. The clinician view renders both blocks from
the same payload. RMSSD joins the dense day-bucket path so long windows
stay aggregated.
2026-10-21 21:55:57 +02:00

126 lines
4.0 KiB
TypeScript

/**
* The workouts block of the doctor report: sessions over the window, summed
* per sport.
*
* A clinician reads training volume, not a session log, so the block is a
* per-sport summary (sessions, total time, distance, energy, mean of the
* session heart-rate averages) plus the window's weekly figures. Workouts are
* a sparse table — a handful a week even for a daily athlete — so the rows are
* read narrow (no metadata, no samples, no route) and collapsed through the
* canonical-workout picker first, the same rule the workout list applies, so a
* session two sources reported counts once.
*/
import { prisma } from "@/lib/db";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import type { DoctorReportWorkouts } from "@/lib/doctor-report-types";
/**
* Sport rows the block lists at most, longest total time first. The totals
* above the table still count every session, and `sportCount` says how many
* sports there were.
*/
export const REPORT_WORKOUT_SPORT_CAP = 12;
export async function loadReportWorkouts(params: {
userId: string;
start: Date;
end: Date;
days: number;
sourcePriorityJson: unknown;
}): Promise<DoctorReportWorkouts | null> {
const { userId, start, end, days, sourcePriorityJson } = params;
const rows = await prisma.workout.findMany({
where: { userId, startedAt: { gte: start, lte: end } },
orderBy: { startedAt: "asc" },
select: {
sportType: true,
startedAt: true,
durationSec: true,
source: true,
totalDistanceM: true,
totalEnergyKcal: true,
avgHeartRate: true,
},
});
const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson);
return summariseReportWorkouts(canonical, days);
}
/** Pure fold, exported for tests. */
export function summariseReportWorkouts(
rows: ReadonlyArray<{
sportType: string;
durationSec: number;
totalDistanceM: number | null;
totalEnergyKcal: number | null;
avgHeartRate: number | null;
}>,
days: number,
): DoctorReportWorkouts | null {
if (rows.length === 0) return null;
const bySport = new Map<
string,
{
sessions: number;
durationSec: number;
distanceM: number;
distanceSessions: number;
energyKcal: number;
energySessions: number;
hrSum: number;
hrSessions: number;
}
>();
let totalDurationSec = 0;
for (const r of rows) {
totalDurationSec += r.durationSec;
const s = bySport.get(r.sportType) ?? {
sessions: 0,
durationSec: 0,
distanceM: 0,
distanceSessions: 0,
energyKcal: 0,
energySessions: 0,
hrSum: 0,
hrSessions: 0,
};
s.sessions += 1;
s.durationSec += r.durationSec;
if (r.totalDistanceM !== null && r.totalDistanceM > 0) {
s.distanceM += r.totalDistanceM;
s.distanceSessions += 1;
}
if (r.totalEnergyKcal !== null && r.totalEnergyKcal > 0) {
s.energyKcal += r.totalEnergyKcal;
s.energySessions += 1;
}
if (r.avgHeartRate !== null && r.avgHeartRate > 0) {
s.hrSum += r.avgHeartRate;
s.hrSessions += 1;
}
bySport.set(r.sportType, s);
}
const weeks = Math.max(days, 1) / 7;
return {
sessions: rows.length,
totalDurationSec,
sessionsPerWeek: rows.length / weeks,
minutesPerWeek: totalDurationSec / 60 / weeks,
sportCount: bySport.size,
bySport: [...bySport.entries()]
.sort((a, b) => b[1].durationSec - a[1].durationSec)
.slice(0, REPORT_WORKOUT_SPORT_CAP)
.map(([sportType, s]) => ({
sportType,
sessions: s.sessions,
durationSec: s.durationSec,
// A sum over the sessions that reported it, and how many did: a
// partial distance never reads as the sport's whole distance.
distanceM: s.distanceSessions > 0 ? s.distanceM : null,
distanceSessions: s.distanceSessions,
energyKcal: s.energySessions > 0 ? s.energyKcal : null,
avgHeartRate: s.hrSessions > 0 ? s.hrSum / s.hrSessions : null,
})),
};
}