feat(workouts): show fitness, fatigue and form from the stored training impulse

The first page of the workout list now carries the fitness-fatigue model
over the day-total heart-rate impulse the nightly strain run already
stores: fitness as the 42-day and fatigue as the 7-day exponentially
weighted average, form as their difference. A day without a stored impulse
counts as rest. It needs 28 days and three training days, flags the first
six weeks as warming up, and reads in the person's own units only. The
workouts page shows the three figures above the list.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:13:28 +02:00
1 parent 5c64192afc
commit 9be720146c
19 files changed
+651 -1

No files matched your search

@@ -31,6 +31,7 @@ const ANALYSIS_MODULES = [
"lib/insights/derived/changepoint.ts",
"lib/labs/lab-trend.ts",
"lib/mental-health/assessment-trend.ts",
"lib/insights/training-load.ts",
] as const;
/** Reachable under `lib/ai/` without being a model: the at-rest cipher. */
@@ -26,6 +26,8 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
},
// v1.44 — the first page carries the training load.
strainTrimpCache: { findMany: vi.fn(async () => []) },
},
}));
@@ -423,3 +425,45 @@ describe("GET /api/workouts — canonical dedup", () => {
);
});
});
describe("GET /api/workouts — training load (v1.44)", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
});
it("carries fitness, fatigue and form on the first page only", async () => {
const yesterday = new Date(Date.now() - 86_400_000)
.toISOString()
.slice(0, 10);
const rows = Array.from({ length: 50 }, (_, i) => ({
day: new Date(
Date.parse(`${yesterday}T00:00:00Z`) - (49 - i) * 86_400_000,
)
.toISOString()
.slice(0, 10),
dayTrimp: 80,
}));
vi.mocked(prisma.strainTrimpCache.findMany).mockResolvedValue(
rows as never,
);
const first = await (await GET(makeRequest())).json();
expect(first.data.trainingLoad).toMatchObject({
status: "ok",
asOf: yesterday,
daysCovered: 50,
trainingDays: 50,
warmingUp: false,
});
const later = await (await GET(makeRequest("?offset=100"))).json();
expect(later.data.trainingLoad).toBeUndefined();
});
});
@@ -15,6 +15,7 @@ import { WorkoutList } from "@/components/insights/workout-list";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { ManualWorkoutSheet } from "@/components/workouts/manual-workout-sheet";
import { TrainingLoadCard } from "@/components/workouts/training-load-card";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { canLogWorkout } from "@/lib/workouts/manual-entry";
@@ -61,6 +62,7 @@ export default function InsightsWorkoutsPageClient() {
total,
isLoading,
isEmpty,
trainingLoad,
isError,
error,
isFetchNextPageError,
@@ -171,6 +173,7 @@ export default function InsightsWorkoutsPageClient() {
})}
</div>
) : null}
<TrainingLoadCard trainingLoad={trainingLoad} />
{workouts.length > 0 ? (
<>
<p
@@ -0,0 +1,59 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
import type { TrainingLoad } from "@/lib/insights/training-load";
import { TrainingLoadCard } from "../training-load-card";
primeLocales("de");
const OK: TrainingLoad = {
status: "ok",
asOf: "2026-10-20",
ctl: 54.2,
atl: 61.8,
tsb: -7.6,
daysCovered: 30,
trainingDays: 18,
warmingUp: true,
series: [],
};
const render = (load: TrainingLoad | undefined, locale: "en" | "de" = "en") =>
renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<TrainingLoadCard trainingLoad={load} />
</I18nProvider>,
);
describe("<TrainingLoadCard>", () => {
it("shows the server's fitness, fatigue and form", () => {
const html = render(OK);
expect(html).toContain("Training load");
expect(html).toMatch(/training-load-fitness[^]*?>54</);
expect(html).toMatch(/training-load-fatigue[^]*?>62</);
expect(html).toContain("-8");
expect(html).toContain("Based on 30 days so far");
});
it("signs a positive form and drops the warm-up note once covered", () => {
const html = render({ ...OK, tsb: 4.4, warmingUp: false }, "de");
expect(html).toContain("+");
expect(html).toContain("Trainingslast");
expect(html).not.toContain("bisher");
});
it("renders nothing while insufficient", () => {
expect(
render({
status: "insufficient",
reason: "too_little_history",
daysCovered: 12,
trainingDays: 2,
}),
).toBe("");
expect(render(undefined)).toBe("");
});
});
@@ -0,0 +1,60 @@
"use client";
import { Gauge } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { TrainingLoad } from "@/lib/insights/training-load";
/**
* v1.44 — fitness, fatigue and form from the stored training impulse, the
* server's figures as given. Renders nothing until the model has 28 days and
* three training days to stand on.
*/
export function TrainingLoadCard({
trainingLoad,
}: {
trainingLoad: TrainingLoad | undefined;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!trainingLoad || trainingLoad.status !== "ok") return null;
const stats = [
{ key: "fitness", value: trainingLoad.ctl },
{ key: "fatigue", value: trainingLoad.atl },
{ key: "form", value: trainingLoad.tsb },
] as const;
return (
<Card data-slot="training-load">
<CardHeader>
<TileHeader icon={Gauge} title={t("insights.workouts.load.title")} />
</CardHeader>
<CardContent className="space-y-2">
<dl className="grid grid-cols-3 gap-3">
{stats.map((stat) => (
<div key={stat.key} data-slot={`training-load-${stat.key}`}>
<dt className="text-muted-foreground text-xs">
{t(`insights.workouts.load.${stat.key}`)}
</dt>
<dd className="text-foreground text-lg font-semibold tabular-nums">
{stat.key === "form" && stat.value > 0 ? "+" : ""}
{fmt.number(stat.value, 0)}
</dd>
</div>
))}
</dl>
<p className="text-sm">{t("insights.workouts.load.explainer")}</p>
{trainingLoad.warmingUp ? (
<p className="text-muted-foreground text-xs">
{t("insights.workouts.load.warmingUp", {
days: trainingLoad.daysCovered,
})}
</p>
) : null}
</CardContent>
</Card>
);
}
+7
View File
@@ -1,5 +1,6 @@
"use client";
import type { TrainingLoad } from "@/lib/insights/training-load";
import { useMemo } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
@@ -55,6 +56,8 @@ export interface WorkoutListMeta {
export interface WorkoutListPayload {
workouts: WorkoutListEntry[];
meta: WorkoutListMeta;
/** v1.44 — first page only; see `GET /api/workouts`. */
trainingLoad?: TrainingLoad;
}
export interface UseWorkoutsOptions {
@@ -179,6 +182,8 @@ export interface UseInfiniteWorkoutsResult {
total: number;
isLoading: boolean;
isEmpty: boolean;
/** v1.44 — fitness, fatigue and form, from the first page. */
trainingLoad: TrainingLoad | undefined;
error: Error | null;
/** True when the first page failed and there is no history to preserve. */
isError: boolean;
@@ -218,6 +223,7 @@ export function useInfiniteWorkouts(
[pages],
);
const total = query.data?.pages[0]?.meta.total ?? 0;
const trainingLoad = query.data?.pages[0]?.trainingLoad;
const isEmpty =
Boolean(isAuthenticated) &&
query.data !== undefined &&
@@ -228,6 +234,7 @@ export function useInfiniteWorkouts(
total,
isLoading: query.isLoading,
isEmpty,
trainingLoad,
error: query.error as Error | null,
isError: query.isError && query.data === undefined,
isFetchNextPageError: query.isFetchNextPageError,
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { shiftDateKey } from "@/lib/tz/format";
import { computeTrainingLoad } from "../training-load";
const D0 = "2026-01-01";
const day = (n: number) => shiftDateKey(D0, n);
describe("computeTrainingLoad", () => {
it("matches the closed form for a constant daily impulse", () => {
// 100 every day for 60 days: CTL = 100·(1 − (41/42)^60),
// ATL = 100·(1 − (6/7)^60).
const rows = Array.from({ length: 60 }, (_, i) => ({
day: day(i),
dayTrimp: 100,
}));
const load = computeTrainingLoad(rows, day(59));
if (load.status !== "ok") throw new Error("expected ok");
const ctl = 100 * (1 - (41 / 42) ** 60);
const atl = 100 * (1 - (6 / 7) ** 60);
expect(load.ctl).toBe(Math.round(ctl * 10) / 10);
expect(load.atl).toBe(Math.round(atl * 10) / 10);
expect(load.tsb).toBe(Math.round((ctl - atl) * 10) / 10);
expect(load.daysCovered).toBe(60);
expect(load.trainingDays).toBe(60);
expect(load.warmingUp).toBe(false);
expect(load.series).toHaveLength(60);
// Form on day 0 is taken going into the day: both averages are still 0.
expect(load.series[0]).toEqual({
day: day(0),
ctl: 2.4,
atl: 14.3,
tsb: 0,
});
});
it("counts a day without a stored row as a rest day", () => {
// Train on day 0 only, then 29 empty days up to day 29.
const load = computeTrainingLoad(
[
{ day: day(0), dayTrimp: 84 },
{ day: day(1), dayTrimp: 42 },
{ day: day(2), dayTrimp: 42 },
],
day(29),
);
if (load.status !== "ok") throw new Error("expected ok");
let ctl = 0;
let atl = 0;
for (let i = 0; i < 30; i += 1) {
const l = i === 0 ? 84 : i < 3 ? 42 : 0;
ctl += (l - ctl) / 42;
atl += (l - atl) / 7;
}
expect(load.ctl).toBe(Math.round(ctl * 10) / 10);
expect(load.atl).toBe(Math.round(atl * 10) / 10);
expect(load.daysCovered).toBe(30);
expect(load.warmingUp).toBe(true);
});
it("answers insufficient without impulse or with too little history", () => {
expect(computeTrainingLoad([], day(10))).toMatchObject({
status: "insufficient",
reason: "no_impulse_history",
});
expect(
computeTrainingLoad([{ day: day(0), dayTrimp: 0 }], day(40)),
).toMatchObject({ status: "insufficient", reason: "no_impulse_history" });
expect(
computeTrainingLoad(
Array.from({ length: 10 }, (_, i) => ({ day: day(i), dayTrimp: 50 })),
day(9),
),
).toMatchObject({
status: "insufficient",
reason: "too_little_history",
daysCovered: 10,
});
});
it("ignores rows after the day it describes", () => {
const rows = Array.from({ length: 40 }, (_, i) => ({
day: day(i),
dayTrimp: 60,
}));
const a = computeTrainingLoad(rows, day(29));
const b = computeTrainingLoad(rows.slice(0, 30), day(29));
expect(a).toEqual(b);
});
});
describe("readTrainingLoad", () => {
it("describes the day the strain run scored", async () => {
const { vi } = await import("vitest");
vi.resetModules();
const findMany = vi.fn(async () => []);
vi.doMock("@/lib/db", () => ({
prisma: { strainTrimpCache: { findMany } },
}));
const { readTrainingLoad } = await import("../training-load");
const { scoreDayKey } = await import("@/lib/insights/score-row");
const now = new Date("2026-10-21T01:30:00.000Z");
await readTrainingLoad("u1", now);
const args = (findMany.mock.calls[0] as unknown[])[0] as {
where: { day: { lte: string } };
};
expect(args.where.day.lte).toBe(scoreDayKey(now));
vi.doUnmock("@/lib/db");
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* v1.44 — training load as fitness, fatigue and form, from the stored daily
* training impulse.
*
* The impulse is the Banister TRIMP the nightly strain run already writes to
* `strain_trimp_cache` (one row per scored UTC day; a day without a row
* carried no impulse). Over it run the two exponentially weighted averages
* of the fitness-fatigue model (Banister, Calvert, Savage & Bach 1975; in the
* time-constant form popularised as chronic and acute training load):
*
* fitness CTL_d = CTL_{d−1} + (L_d − CTL_{d−1}) / 42
* fatigue ATL_d = ATL_{d−1} + (L_d − ATL_{d−1}) / 7
* form TSB_d = CTL_{d−1} − ATL_{d−1} (going into day d)
*
* Both averages start at zero on the first stored day, so the figures are
* flagged `warmingUp` until {@link CTL_DAYS} days are covered. The values are
* in TRIMP units: comparable with the person's own past, not with anyone
* else's and not with another app's training-stress score. Descriptive only:
* no performance prediction and no advice. Pure apart from the one read.
*/
import { prisma } from "@/lib/db";
import { shiftDateKey } from "@/lib/tz/format";
export const CTL_DAYS = 42;
export const ATL_DAYS = 7;
/** Days of history before the figures are offered at all. */
export const TRAINING_LOAD_MIN_DAYS = 28;
/** Training days (impulse > 0) before the figures are offered at all. */
export const TRAINING_LOAD_MIN_TRAINING_DAYS = 3;
/** Days of history read; the seed's pull is gone long before. */
export const TRAINING_LOAD_READ_DAYS = 365;
/** Trailing days of the series returned for a chart. */
export const TRAINING_LOAD_SERIES_DAYS = 90;
export interface TrainingLoadPoint {
day: string;
ctl: number;
atl: number;
tsb: number;
}
export type TrainingLoad =
| {
status: "ok";
/** The last scored day the figures describe (UTC day key). */
asOf: string;
ctl: number;
atl: number;
/** Form going into the day after `asOf`: CTL − ATL at `asOf`. */
tsb: number;
daysCovered: number;
trainingDays: number;
warmingUp: boolean;
series: TrainingLoadPoint[];
}
| {
status: "insufficient";
reason: "no_impulse_history" | "too_little_history";
daysCovered: number;
trainingDays: number;
};
const round1 = (x: number) => Math.round(x * 10) / 10;
/**
* Run the model from the first stored day through `asOf` (inclusive), a
* missing day counting as zero impulse. Rows after `asOf` are ignored.
*/
export function computeTrainingLoad(
rows: ReadonlyArray<{ day: string; dayTrimp: number }>,
asOf: string,
): TrainingLoad {
const byDay = new Map<string, number>();
for (const row of rows) {
if (row.day > asOf || !Number.isFinite(row.dayTrimp)) continue;
byDay.set(row.day, Math.max(0, row.dayTrimp));
}
const days = [...byDay.keys()].sort();
const trainingDays = [...byDay.values()].filter((v) => v > 0).length;
if (days.length === 0 || trainingDays === 0) {
return {
status: "insufficient",
reason: "no_impulse_history",
daysCovered: 0,
trainingDays,
};
}
const series: TrainingLoadPoint[] = [];
let ctl = 0;
let atl = 0;
let daysCovered = 0;
for (let day = days[0]; day <= asOf; day = shiftDateKey(day, 1)) {
const tsb = ctl - atl;
const load = byDay.get(day) ?? 0;
ctl += (load - ctl) / CTL_DAYS;
atl += (load - atl) / ATL_DAYS;
daysCovered += 1;
series.push({ day, ctl, atl, tsb });
}
if (
daysCovered < TRAINING_LOAD_MIN_DAYS ||
trainingDays < TRAINING_LOAD_MIN_TRAINING_DAYS
) {
return {
status: "insufficient",
reason: "too_little_history",
daysCovered,
trainingDays,
};
}
return {
status: "ok",
asOf,
ctl: round1(ctl),
atl: round1(atl),
tsb: round1(ctl - atl),
daysCovered,
trainingDays,
warmingUp: daysCovered < CTL_DAYS,
series: series.slice(-TRAINING_LOAD_SERIES_DAYS).map((p) => ({
day: p.day,
ctl: round1(p.ctl),
atl: round1(p.atl),
tsb: round1(p.tsb),
})),
};
}
/** Read the stored impulse and run the model up to the last scored day. */
export async function readTrainingLoad(
userId: string,
now: Date = new Date(),
): Promise<TrainingLoad> {
// The strain run scores the previous UTC day (`scoreDayKey`). Restated
// here rather than imported: `score-row` sits on the score writers' import
// graph, and this read stays off it.
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day
const asOf = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10);
const rows = await prisma.strainTrimpCache.findMany({
where: {
userId,
day: { gte: shiftDateKey(asOf, -TRAINING_LOAD_READ_DAYS), lte: asOf },
},
orderBy: { day: "asc" },
take: TRAINING_LOAD_READ_DAYS + 1,
select: { day: true, dayTrimp: true },
});
return computeTrainingLoad(rows, asOf);
}
+46
View File
@@ -110,6 +110,47 @@ const workoutListEntry = z
})
.meta({ id: "WorkoutListEntry" });
const trainingLoadPoint = z.object({
day: z.string(),
ctl: z.number(),
atl: z.number(),
tsb: z.number(),
});
const trainingLoad = z
.union([
z.object({
status: z.literal("ok"),
asOf: z
.string()
.describe("The last scored UTC day the figures describe."),
ctl: z.number().describe("Fitness: 42-day exponentially weighted TRIMP."),
atl: z.number().describe("Fatigue: 7-day exponentially weighted TRIMP."),
tsb: z.number().describe("Form: fitness minus fatigue at `asOf`."),
daysCovered: z.number().int(),
trainingDays: z.number().int(),
warmingUp: z
.boolean()
.describe("True below 42 covered days: both averages start at zero."),
series: z
.array(trainingLoadPoint)
.describe(
"The trailing 90 days, oldest first; `tsb` is form going into the day.",
),
}),
z.object({
status: z.literal("insufficient"),
reason: z.enum(["no_impulse_history", "too_little_history"]),
daysCovered: z.number().int(),
trainingDays: z.number().int(),
}),
])
.meta({
id: "WorkoutTrainingLoad",
description:
"v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day without a stored impulse counts as rest). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.",
});
const workoutListResponse = z
.object({
workouts: z.array(workoutListEntry),
@@ -119,6 +160,11 @@ const workoutListResponse = z
offset: z.number().int().nonnegative(),
droppedDuplicates: z.number().int().nonnegative(),
}),
trainingLoad: trainingLoad
.optional()
.describe(
"v1.44 — on the first page (`offset=0`) only, account-wide whatever the filter. Absent on later pages and older servers.",
),
})
.meta({ id: "WorkoutListResponse" });
+13 -1
View File
@@ -22,6 +22,10 @@ import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-work
import { cached, caches } from "@/lib/cache/server-cache";
import { workoutSportTypeEnum } from "@/lib/validations/workout";
import { isoDateTime } from "@/lib/validations/datetime";
import {
readTrainingLoad,
type TrainingLoad,
} from "@/lib/insights/training-load";
function emptyQueryValueToUndefined(value: unknown): unknown {
if (value == null) return undefined;
@@ -113,6 +117,12 @@ export interface WorkoutsResult {
offset: number;
droppedDuplicates: number;
};
/**
* v1.44 — fitness, fatigue and form from the stored training impulse.
* Account-wide (no filter applies) and on the first page only, so a client
* paging through history does not recompute it per page.
*/
trainingLoad?: TrainingLoad;
}
export async function buildWorkoutsProjection(
@@ -257,5 +267,7 @@ export async function readWorkoutsListCached(
annotate,
);
return sliceWorkoutsProjection(projection, params.limit, params.offset);
const page = sliceWorkoutsProjection(projection, params.limit, params.offset);
if (params.offset !== 0) return page;
return { ...page, trainingLoad: await readTrainingLoad(userId) };
}