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

+21 -4
View File
@@ -49454,7 +49454,8 @@ components:
const: ok
asOf:
type: string
description: The last scored UTC day the figures describe.
description: "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:
type: number
description: "Fitness: 42-day exponentially weighted TRIMP."
@@ -49472,6 +49473,15 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
recentRatedDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse.
recentDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
warmingUp:
type: boolean
description: "True below 42 covered days: both averages start at zero."
@@ -49488,11 +49498,16 @@ components:
type: number
tsb:
type: number
rated:
type: boolean
description: "False for a day without a stored impulse (never scored): the averages held through it rather than counting
it as rest."
required:
- day
- ctl
- atl
- tsb
- rated
additionalProperties: false
description: The trailing 90 days, oldest first; `tsb` is form going into the day.
required:
@@ -49503,6 +49518,8 @@ components:
- tsb
- daysCovered
- trainingDays
- recentRatedDays
- recentDays
- warmingUp
- series
additionalProperties: false
@@ -49530,9 +49547,9 @@ components:
- daysCovered
- trainingDays
additionalProperties: false
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."
description: "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."
GetWorkoutResponse:
type: object
properties:
+2 -1
View File
@@ -3898,7 +3898,8 @@
"fatigue": "Ermüdung",
"form": "Form",
"explainer": "Fitness ist dein Trainingsimpuls aus der Herzfrequenz, gemittelt über etwa sechs Wochen, Ermüdung über etwa eine Woche, und Form ist der Unterschied. Die Zahlen lassen sich mit deinen eigenen früheren vergleichen, nicht mit anderen Menschen oder Apps.",
"warmingUp": "Auf Basis von bisher {days} Tagen: Bis sechs Wochen abgedeckt sind, fällt die Fitness niedriger aus, als sie ist."
"warmingUp": "Auf Basis von bisher {days} Tagen: Bis sechs Wochen abgedeckt sind, fällt die Fitness niedriger aus, als sie ist.",
"coverage": "{rated} der letzten {days} Tage haben Herzfrequenzdaten. Tage ohne sie bleiben außen vor, statt als Ruhetag zu zählen."
}
},
"bloodGlucose": {
+2 -1
View File
@@ -3898,7 +3898,8 @@
"fatigue": "Fatigue",
"form": "Form",
"explainer": "Fitness is your heart-rate training impulse averaged over about six weeks, fatigue over about one week, and form is the difference. The numbers compare with your own past, not with other people or other apps.",
"warmingUp": "Based on {days} days so far: until six weeks are covered, fitness reads lower than it is."
"warmingUp": "Based on {days} days so far: until six weeks are covered, fitness reads lower than it is.",
"coverage": "{rated} of the last {days} days have heart-rate data. Days without it are left out rather than counted as rest."
}
},
"bloodGlucose": {
+2 -1
View File
@@ -3898,7 +3898,8 @@
"fatigue": "Fatiga",
"form": "Estado",
"explainer": "La forma de base es tu impulso de entrenamiento por frecuencia cardiaca promediado en unas seis semanas, la fatiga en una semana, y el estado es la diferencia. Las cifras se comparan con tu propio pasado, no con otras personas ni otras apps.",
"warmingUp": "Con {days} días hasta ahora: hasta cubrir seis semanas, la forma de base aparece más baja de lo que es."
"warmingUp": "Con {days} días hasta ahora: hasta cubrir seis semanas, la forma de base aparece más baja de lo que es.",
"coverage": "{rated} de los últimos {days} días tienen datos de frecuencia cardiaca. Los días sin ellos se omiten en lugar de contar como descanso."
}
},
"bloodGlucose": {
+2 -1
View File
@@ -3898,7 +3898,8 @@
"fatigue": "Fatigue",
"form": "Fraîcheur",
"explainer": "La condition est ton impulsion d'entraînement par fréquence cardiaque moyennée sur environ six semaines, la fatigue sur environ une semaine, et la fraîcheur est la différence. Les chiffres se comparent à ton propre passé, pas à d'autres personnes ni à d'autres apps.",
"warmingUp": "Sur {days} jours pour l'instant : tant que six semaines ne sont pas couvertes, la condition paraît plus basse qu'elle ne l'est."
"warmingUp": "Sur {days} jours pour l'instant : tant que six semaines ne sont pas couvertes, la condition paraît plus basse qu'elle ne l'est.",
"coverage": "{rated} des {days} derniers jours ont des données de fréquence cardiaque. Les jours sans elles sont écartés au lieu de compter comme repos."
}
},
"bloodGlucose": {
+2 -1
View File
@@ -3898,7 +3898,8 @@
"fatigue": "Affaticamento",
"form": "Freschezza",
"explainer": "La forma di base è il tuo impulso di allenamento dalla frequenza cardiaca mediato su circa sei settimane, l'affaticamento su circa una settimana, e la freschezza è la differenza. I numeri si confrontano con il tuo passato, non con altre persone o altre app.",
"warmingUp": "Su {days} giorni finora: finché non sono coperte sei settimane, la forma di base risulta più bassa di quanto sia."
"warmingUp": "Su {days} giorni finora: finché non sono coperte sei settimane, la forma di base risulta più bassa di quanto sia.",
"coverage": "{rated} degli ultimi {days} giorni hanno dati di frequenza cardiaca. I giorni senza vengono esclusi invece di contare come riposo."
}
},
"bloodGlucose": {
+2 -1
View File
@@ -3898,7 +3898,8 @@
"fatigue": "피로",
"form": "컨디션",
"explainer": "체력은 심박 기반 훈련 자극을 약 6주 동안, 피로는 약 1주 동안 평균한 값이고, 컨디션은 그 차이입니다. 이 숫자는 다른 사람이나 다른 앱이 아니라 내 과거와 비교하는 값입니다.",
"warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다."
"warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다.",
"coverage": "최근 {days}일 중 {rated}일에 심박수 데이터가 있습니다. 데이터가 없는 날은 휴식일로 세지 않고 제외합니다."
}
},
"bloodGlucose": {
+2 -1
View File
@@ -3898,7 +3898,8 @@
"fatigue": "Vermoeidheid",
"form": "Vorm",
"explainer": "Fitheid is je trainingsprikkel uit de hartslag, gemiddeld over ongeveer zes weken, vermoeidheid over ongeveer een week, en vorm is het verschil. De getallen zijn te vergelijken met je eigen verleden, niet met andere mensen of andere apps.",
"warmingUp": "Op basis van {days} dagen tot nu toe: tot zes weken gedekt zijn, valt de fitheid lager uit dan ze is."
"warmingUp": "Op basis van {days} dagen tot nu toe: tot zes weken gedekt zijn, valt de fitheid lager uit dan ze is.",
"coverage": "{rated} van de laatste {days} dagen hebben hartslaggegevens. Dagen zonder worden weggelaten in plaats van als rustdag te tellen."
}
},
"bloodGlucose": {
+2 -1
View File
@@ -3898,7 +3898,8 @@
"fatigue": "Zmęczenie",
"form": "Forma",
"explainer": "Wydolność to twój bodziec treningowy z tętna uśredniony z około sześciu tygodni, zmęczenie z około tygodnia, a forma to różnica. Liczby porównuje się z twoją własną przeszłością, nie z innymi osobami ani aplikacjami.",
"warmingUp": "Na podstawie dotychczasowych {days} dni: dopóki nie minie sześć tygodni, wydolność wychodzi niższa, niż jest."
"warmingUp": "Na podstawie dotychczasowych {days} dni: dopóki nie minie sześć tygodni, wydolność wychodzi niższa, niż jest.",
"coverage": "{rated} z ostatnich {days} dni ma dane tętna. Dni bez nich są pomijane, a nie liczone jako odpoczynek."
}
},
"bloodGlucose": {
@@ -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