fix(workouts): hold training load through unscored days

A day without a stored training impulse was read as a rest day, so a
watch left off or a nightly run that had not happened yet pulled
fitness and fatigue toward zero. Such a day is now not rated: the
averages hold through it, the series marks it, and the card says how
many of the last 42 days were rated. A scored day of 0 stays rest. The
figures describe the last stored day rather than always yesterday.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 23:09:09 +02:00
1 parent 2723ac5b98
commit 99dfd68aad
15 files changed
+145 -38

No files matched your search

@@ -461,6 +461,8 @@ describe("GET /api/workouts — training load (v1.44)", () => {
asOf: yesterday,
daysCovered: 50,
trainingDays: 50,
recentRatedDays: 42,
recentDays: 42,
warmingUp: false,
});
const later = await (await GET(makeRequest("?offset=100"))).json();
@@ -17,6 +17,8 @@ const OK: TrainingLoad = {
tsb: -7.6,
daysCovered: 30,
trainingDays: 18,
recentRatedDays: 30,
recentDays: 30,
warmingUp: true,
series: [],
};
@@ -36,6 +38,13 @@ describe("<TrainingLoadCard>", () => {
expect(html).toMatch(/training-load-fatigue[^]*?>62</);
expect(html).toContain("-8");
expect(html).toContain("Based on 30 days so far");
expect(html).not.toContain('data-slot="training-load-coverage"');
});
it("says how many recent days were rated when some were not", () => {
const html = render({ ...OK, recentRatedDays: 25, recentDays: 42 });
expect(html).toContain('data-slot="training-load-coverage"');
expect(html).toContain("25 of the last 42 days have heart-rate data");
});
it("signs a positive form and drops the warm-up note once covered", () => {
@@ -47,6 +47,17 @@ export function TrainingLoadCard({
))}
</dl>
<p className="text-sm">{t("insights.workouts.load.explainer")}</p>
{trainingLoad.recentRatedDays < trainingLoad.recentDays ? (
<p
className="text-muted-foreground text-xs"
data-slot="training-load-coverage"
>
{t("insights.workouts.load.coverage", {
rated: trainingLoad.recentRatedDays,
days: trainingLoad.recentDays,
})}
</p>
) : null}
{trainingLoad.warmingUp ? (
<p className="text-muted-foreground text-xs">
{t("insights.workouts.load.warmingUp", {
@@ -31,23 +31,29 @@ describe("computeTrainingLoad", () => {
ctl: 2.4,
atl: 14.3,
tsb: 0,
rated: true,
});
expect(load.recentRatedDays).toBe(42);
expect(load.recentDays).toBe(42);
});
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),
);
it("counts a scored day of 0 as rest, and holds through a day never scored", () => {
// Three training days, then scored rest days 3..29 except a gap of
// unscored days 10..19 (no heart-rate data reached the server).
const rows = [
{ day: day(0), dayTrimp: 84 },
{ day: day(1), dayTrimp: 42 },
{ day: day(2), dayTrimp: 42 },
];
for (let i = 3; i < 30; i += 1) {
if (i < 10 || i > 19) rows.push({ day: day(i), dayTrimp: 0 });
}
const load = computeTrainingLoad(rows, 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) {
if (i >= 10 && i <= 19) continue; // held
const l = i === 0 ? 84 : i < 3 ? 42 : 0;
ctl += (l - ctl) / 42;
atl += (l - atl) / 7;
@@ -55,9 +61,26 @@ describe("computeTrainingLoad", () => {
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.recentRatedDays).toBe(20);
expect(load.recentDays).toBe(30);
expect(load.series.find((p) => p.day === day(12))?.rated).toBe(false);
expect(load.series.find((p) => p.day === day(25))?.rated).toBe(true);
expect(load.warmingUp).toBe(true);
});
it("describes the last stored day, so a run not yet made never zeroes it", () => {
// Rows through day 39; asked for day 41 (the nightly run for 40 and 41
// has not happened). The figures are those of day 39.
const rows = Array.from({ length: 40 }, (_, i) => ({
day: day(i),
dayTrimp: 60,
}));
const late = computeTrainingLoad(rows, day(41));
const exact = computeTrainingLoad(rows, day(39));
expect(late).toEqual(exact);
expect(late).toMatchObject({ status: "ok", asOf: day(39) });
});
it("answers insufficient without impulse or with too little history", () => {
expect(computeTrainingLoad([], day(10))).toMatchObject({
status: "insufficient",
+37 -14
View File
@@ -3,8 +3,13 @@
* 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
* `strain_trimp_cache`: one row per scored UTC day, 0 on a scored day without
* training. A day WITHOUT a row was never scored (no heart-rate data reached
* the server, or the run has not happened yet), so it is not rated rather
* than read as rest: the averages hold through it instead of decaying toward
* zero, and the figures report how many recent days were rated. The figures
* describe the last stored day, so a nightly run that has not happened yet
* never turns yesterday into a rest day. 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):
*
@@ -37,6 +42,8 @@ export interface TrainingLoadPoint {
ctl: number;
atl: number;
tsb: number;
/** False for a day without a stored impulse: the averages held through it. */
rated: boolean;
}
export type TrainingLoad =
@@ -50,6 +57,10 @@ export type TrainingLoad =
tsb: number;
daysCovered: number;
trainingDays: number;
/** Days of the trailing {@link CTL_DAYS} (or fewer, when covered less) with a stored impulse. */
recentRatedDays: number;
/** The trailing window `recentRatedDays` counts over. */
recentDays: number;
warmingUp: boolean;
series: TrainingLoadPoint[];
}
@@ -63,16 +74,17 @@ export type TrainingLoad =
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.
* Run the model from the first stored day through the last stored day on or
* before `until`. A day without a row is not rated: the averages hold through
* it. Rows after `until` are ignored.
*/
export function computeTrainingLoad(
rows: ReadonlyArray<{ day: string; dayTrimp: number }>,
asOf: string,
until: string,
): TrainingLoad {
const byDay = new Map<string, number>();
for (const row of rows) {
if (row.day > asOf || !Number.isFinite(row.dayTrimp)) continue;
if (row.day > until || !Number.isFinite(row.dayTrimp)) continue;
byDay.set(row.day, Math.max(0, row.dayTrimp));
}
const days = [...byDay.keys()].sort();
@@ -86,18 +98,23 @@ export function computeTrainingLoad(
};
}
const asOf = days[days.length - 1];
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;
const load = byDay.get(day);
if (load !== undefined) {
ctl += (load - ctl) / CTL_DAYS;
atl += (load - atl) / ATL_DAYS;
}
daysCovered += 1;
series.push({ day, ctl, atl, tsb });
series.push({ day, ctl, atl, tsb, rated: load !== undefined });
}
const recent = series.slice(-CTL_DAYS);
const recentRatedDays = recent.filter((p) => p.rated).length;
if (
daysCovered < TRAINING_LOAD_MIN_DAYS ||
@@ -119,17 +136,23 @@ export function computeTrainingLoad(
tsb: round1(ctl - atl),
daysCovered,
trainingDays,
recentRatedDays,
recentDays: recent.length,
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),
rated: p.rated,
})),
};
}
/** Read the stored impulse and run the model up to the last scored day. */
/**
* Read the stored impulse and run the model up to the last stored day, at
* most the day the strain run scores (yesterday in UTC).
*/
export async function readTrainingLoad(
userId: string,
now: Date = new Date(),
@@ -138,15 +161,15 @@ export async function readTrainingLoad(
// 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 until = 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 },
day: { gte: shiftDateKey(until, -TRAINING_LOAD_READ_DAYS), lte: until },
},
orderBy: { day: "asc" },
take: TRAINING_LOAD_READ_DAYS + 1,
select: { day: true, dayTrimp: true },
});
return computeTrainingLoad(rows, asOf);
return computeTrainingLoad(rows, until);
}
+16 -2
View File
@@ -115,6 +115,11 @@ const trainingLoadPoint = z.object({
ctl: z.number(),
atl: z.number(),
tsb: z.number(),
rated: z
.boolean()
.describe(
"False for a day without a stored impulse (never scored): the averages held through it rather than counting it as rest.",
),
});
const trainingLoad = z
@@ -123,12 +128,21 @@ const trainingLoad = z
status: z.literal("ok"),
asOf: z
.string()
.describe("The last scored UTC day the figures describe."),
.describe(
"The last UTC day with a stored impulse, at most yesterday: a nightly run that has not happened yet never reads as a rest day.",
),
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(),
recentRatedDays: z
.number()
.int()
.describe(
"Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse.",
),
recentDays: z.number().int(),
warmingUp: z
.boolean()
.describe("True below 42 covered days: both averages start at zero."),
@@ -148,7 +162,7 @@ const trainingLoad = z
.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.",
"v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a scored day without training is 0, a day without a stored impulse is not rated and the averages hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.",
});
const workoutListResponse = z