diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6a072be7d..9c77d2a05 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index e1083aa25..47417f3ab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index a90ca8022..8f8fd6fa6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 032dbfaca..02f80d63e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index c41d73f87..70036c66d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index fa3fd0fac..362937e74 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 0777b5263..4ce92c2d9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3898,7 +3898,8 @@ "fatigue": "피로", "form": "컨디션", "explainer": "체력은 심박 기반 훈련 자극을 약 6주 동안, 피로는 약 1주 동안 평균한 값이고, 컨디션은 그 차이입니다. 이 숫자는 다른 사람이나 다른 앱이 아니라 내 과거와 비교하는 값입니다.", - "warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다." + "warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다.", + "coverage": "최근 {days}일 중 {rated}일에 심박수 데이터가 있습니다. 데이터가 없는 날은 휴식일로 세지 않고 제외합니다." } }, "bloodGlucose": { diff --git a/messages/nl.json b/messages/nl.json index ecd903c04..dbe122e64 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index fd13d2ec0..3f5960574 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index 82906eb8e..dd05951ff 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -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(); diff --git a/src/components/workouts/__tests__/training-load-card.test.tsx b/src/components/workouts/__tests__/training-load-card.test.tsx index 27d9dcd62..32f734eb3 100644 --- a/src/components/workouts/__tests__/training-load-card.test.tsx +++ b/src/components/workouts/__tests__/training-load-card.test.tsx @@ -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("", () => { expect(html).toMatch(/training-load-fatigue[^]*?>62 { + 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", () => { diff --git a/src/components/workouts/training-load-card.tsx b/src/components/workouts/training-load-card.tsx index 530fec929..08b76f91a 100644 --- a/src/components/workouts/training-load-card.tsx +++ b/src/components/workouts/training-load-card.tsx @@ -47,6 +47,17 @@ export function TrainingLoadCard({ ))}

{t("insights.workouts.load.explainer")}

+ {trainingLoad.recentRatedDays < trainingLoad.recentDays ? ( +

+ {t("insights.workouts.load.coverage", { + rated: trainingLoad.recentRatedDays, + days: trainingLoad.recentDays, + })} +

+ ) : null} {trainingLoad.warmingUp ? (

{t("insights.workouts.load.warmingUp", { diff --git a/src/lib/insights/__tests__/training-load.test.ts b/src/lib/insights/__tests__/training-load.test.ts index 2e1e33ae2..bd5994013 100644 --- a/src/lib/insights/__tests__/training-load.test.ts +++ b/src/lib/insights/__tests__/training-load.test.ts @@ -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", diff --git a/src/lib/insights/training-load.ts b/src/lib/insights/training-load.ts index b96eaa865..7c56444fb 100644 --- a/src/lib/insights/training-load.ts +++ b/src/lib/insights/training-load.ts @@ -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(); 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); } diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 217766ab0..a64febd99 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -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