diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 808c289bc..0adfaa593 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -49472,8 +49472,8 @@ components: const: ok asOf: type: string - 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." + description: The last rated UTC day, at most yesterday; the figures are those of this day. The series runs on to + yesterday, so a later stretch without data shows as unrated days. ctl: type: number description: "Fitness: 42-day exponentially weighted TRIMP." @@ -49495,7 +49495,8 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 - description: Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse. + description: "Rated days of the trailing `recentDays` (42 up to yesterday, or fewer when covered less): a stored + impulse, or activity data without a workout." recentDays: type: integer minimum: -9007199254740991 @@ -49518,8 +49519,9 @@ components: 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." + description: "False for a day that could not be rated: no stored impulse and no activity data (steps, heart rate, active + energy), or a workout that was never scored. The averages held through it rather than counting it + as rest. A day with activity data and no workout is rest (impulse 0)." required: - day - ctl @@ -49565,9 +49567,10 @@ components: - daysCovered - trainingDays additionalProperties: false - 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." + description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day + with activity data but no training is 0, a day with no data or an unscored workout 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 d78afea07..5a5de7ff2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3903,7 +3903,8 @@ "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.", - "coverage": "{rated} der letzten {days} Tage haben Herzfrequenzdaten. Tage ohne sie bleiben außen vor, statt als Ruhetag zu zählen." + "coverage": "{rated} der letzten {days} Tage ließen sich bewerten. Ein Tag ohne Aktivitätsdaten oder mit einem Training, das sich nicht auswerten ließ, bleibt außen vor, statt als Ruhetag zu zählen.", + "asOf": "Stand {date}, der letzte Tag, der sich bewerten ließ." } }, "bloodGlucose": { diff --git a/messages/en.json b/messages/en.json index 10a8e34e6..6d0e9d027 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3903,7 +3903,8 @@ "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.", - "coverage": "{rated} of the last {days} days have heart-rate data. Days without it are left out rather than counted as rest." + "coverage": "{rated} of the last {days} days could be rated. A day with no activity data, or with a workout that could not be scored, is left out rather than counted as rest.", + "asOf": "As of {date}, the last day that could be rated." } }, "bloodGlucose": { diff --git a/messages/es.json b/messages/es.json index e331d5c2a..b912c397c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3903,7 +3903,8 @@ "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.", - "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." + "coverage": "Se pudieron valorar {rated} de los últimos {days} días. Un día sin datos de actividad, o con un entrenamiento que no se pudo puntuar, se omite en lugar de contar como descanso.", + "asOf": "A fecha de {date}, el último día que se pudo valorar." } }, "bloodGlucose": { diff --git a/messages/fr.json b/messages/fr.json index e4ca44d21..8df07c896 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3903,7 +3903,8 @@ "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.", - "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." + "coverage": "{rated} des {days} derniers jours ont pu être évalués. Un jour sans données d'activité, ou avec un entraînement qui n'a pas pu être évalué, est écarté au lieu de compter comme repos.", + "asOf": "Au {date}, le dernier jour qui a pu être évalué." } }, "bloodGlucose": { diff --git a/messages/it.json b/messages/it.json index 6af2d8b19..e5115f814 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3903,7 +3903,8 @@ "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.", - "coverage": "{rated} degli ultimi {days} giorni hanno dati di frequenza cardiaca. I giorni senza vengono esclusi invece di contare come riposo." + "coverage": "{rated} degli ultimi {days} giorni sono stati valutati. Un giorno senza dati di attività, o con un allenamento che non è stato possibile valutare, viene escluso invece di contare come riposo.", + "asOf": "Al {date}, l'ultimo giorno che è stato possibile valutare." } }, "bloodGlucose": { diff --git a/messages/ko.json b/messages/ko.json index 665971476..cad8527fb 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3903,7 +3903,8 @@ "form": "컨디션", "explainer": "체력은 심박 기반 훈련 자극을 약 6주 동안, 피로는 약 1주 동안 평균한 값이고, 컨디션은 그 차이입니다. 이 숫자는 다른 사람이나 다른 앱이 아니라 내 과거와 비교하는 값입니다.", "warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다.", - "coverage": "최근 {days}일 중 {rated}일에 심박수 데이터가 있습니다. 데이터가 없는 날은 휴식일로 세지 않고 제외합니다." + "coverage": "최근 {days}일 중 {rated}일을 평가할 수 있었습니다. 활동 데이터가 없는 날이나 평가할 수 없는 운동이 있는 날은 휴식일로 세지 않고 제외합니다.", + "asOf": "{date} 기준, 평가할 수 있었던 마지막 날입니다." } }, "bloodGlucose": { diff --git a/messages/nl.json b/messages/nl.json index ab1552351..76e8e3d6c 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -3903,7 +3903,8 @@ "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.", - "coverage": "{rated} van de laatste {days} dagen hebben hartslaggegevens. Dagen zonder worden weggelaten in plaats van als rustdag te tellen." + "coverage": "{rated} van de laatste {days} dagen konden worden beoordeeld. Een dag zonder activiteitsgegevens, of met een training die niet kon worden beoordeeld, wordt weggelaten in plaats van als rustdag te tellen.", + "asOf": "Stand van {date}, de laatste dag die kon worden beoordeeld." } }, "bloodGlucose": { diff --git a/messages/pl.json b/messages/pl.json index 35fe5b2ca..5bd631a54 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3903,7 +3903,8 @@ "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.", - "coverage": "{rated} z ostatnich {days} dni ma dane tętna. Dni bez nich są pomijane, a nie liczone jako odpoczynek." + "coverage": "Udało się ocenić {rated} z ostatnich {days} dni. Dzień bez danych o aktywności lub z treningiem, którego nie dało się ocenić, jest pomijany, a nie liczony jako odpoczynek.", + "asOf": "Stan na {date}, ostatni dzień, który dało się ocenić." } }, "bloodGlucose": { diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index dd05951ff..1386b5d61 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -28,6 +28,8 @@ vi.mock("@/lib/db", () => ({ }, // v1.44 — the first page carries the training load. strainTrimpCache: { findMany: vi.fn(async () => []) }, + measurementRollup: { findMany: vi.fn(async () => []) }, + $queryRaw: vi.fn(async () => []), }, })); diff --git a/src/components/workouts/__tests__/training-load-card.test.tsx b/src/components/workouts/__tests__/training-load-card.test.tsx index 32f734eb3..d78b1e87e 100644 --- a/src/components/workouts/__tests__/training-load-card.test.tsx +++ b/src/components/workouts/__tests__/training-load-card.test.tsx @@ -44,7 +44,19 @@ describe("", () => { 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"); + expect(html).toContain("25 of the last 42 days could be rated"); + }); + + it("names the last rated day when it lies behind the series end", () => { + const series = [ + { day: "2026-10-19", ctl: 54, atl: 61, tsb: -7, rated: false }, + { day: "2026-10-20", ctl: 54, atl: 61, tsb: -7, rated: false }, + ]; + const stale = render({ ...OK, asOf: "2026-09-02", series }); + expect(stale).toContain('data-slot="training-load-as-of"'); + expect(stale).toMatch(/As of 0?9\/0?2|As of 02\/09|As of Sep/); + const fresh = render({ ...OK, asOf: "2026-10-19", series }); + expect(fresh).not.toContain('data-slot="training-load-as-of"'); }); 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 08b76f91a..30f1863a4 100644 --- a/src/components/workouts/training-load-card.tsx +++ b/src/components/workouts/training-load-card.tsx @@ -6,11 +6,14 @@ 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"; +import { shiftDateKey } from "@/lib/tz/format"; /** * 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. + * three training days to stand on. The series ends on the day the strain run + * scores; when the last rated day lies more than a day before that, the card + * names it, so figures held through weeks without data never pass as fresh. */ export function TrainingLoadCard({ trainingLoad, @@ -21,6 +24,10 @@ export function TrainingLoadCard({ const fmt = useFormatters(); if (!trainingLoad || trainingLoad.status !== "ok") return null; + const seriesEnd = trainingLoad.series.at(-1)?.day; + const stale = + seriesEnd !== undefined && trainingLoad.asOf < shiftDateKey(seriesEnd, -1); + const stats = [ { key: "fitness", value: trainingLoad.ctl }, { key: "fatigue", value: trainingLoad.atl }, @@ -47,6 +54,16 @@ export function TrainingLoadCard({ ))}

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

+ {stale ? ( +

+ {t("insights.workouts.load.asOf", { + date: fmt.dateShortSmartCalendar(trainingLoad.asOf), + })} +

+ ) : null} {trainingLoad.recentRatedDays < trainingLoad.recentDays ? (

{ expect(load.warmingUp).toBe(true); }); - it("describes the last stored day, so a run not yet made never zeroes it", () => { + it("holds through days not yet scored and names the last rated day", () => { // 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. + // has not happened, and nothing else is known about them). The figures + // are those of day 39 and the series still runs to day 41. 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) }); + if (late.status !== "ok" || exact.status !== "ok") { + throw new Error("expected ok"); + } + expect(late.asOf).toBe(day(39)); + expect(late.ctl).toBe(exact.ctl); + expect(late.atl).toBe(exact.atl); + expect(late.tsb).toBe(exact.tsb); + expect(late.series.at(-1)).toMatchObject({ day: day(41), rated: false }); + expect(late.recentRatedDays).toBe(40); + expect(late.recentDays).toBe(42); + }); + + it("reads a day with activity data and no workout as rest", () => { + // Training days 0..2, stored rest days 3..9, then no rows at all from + // day 10 on: the strain run writes none on a day without active energy. + // Days 10..59 carry steps or heart rate, except a workout on day 20 that + // could not be scored and an empty day 30. + const rows = [ + { day: day(0), dayTrimp: 84 }, + { day: day(1), dayTrimp: 42 }, + { day: day(2), dayTrimp: 42 }, + ]; + for (let i = 3; i < 10; i += 1) rows.push({ day: day(i), dayTrimp: 0 }); + const dataDays = new Set(); + for (let i = 10; i < 60; i += 1) if (i !== 30) dataDays.add(day(i)); + const load = computeTrainingLoad(rows, day(59), { + dataDays, + workoutDays: new Set([day(20)]), + }); + if (load.status !== "ok") throw new Error("expected ok"); + let ctl = 0; + let atl = 0; + for (let i = 0; i < 60; i += 1) { + if (i === 20 || i === 30) continue; // held + const l = i === 0 ? 84 : i < 3 ? 42 : 0; + ctl += (l - ctl) / 42; + atl += (l - atl) / 7; + } + expect(load.asOf).toBe(day(59)); + expect(load.ctl).toBe(Math.round(ctl * 10) / 10); + expect(load.atl).toBe(Math.round(atl * 10) / 10); + expect(load.series.find((p) => p.day === day(15))?.rated).toBe(true); + expect(load.series.find((p) => p.day === day(20))?.rated).toBe(false); + expect(load.series.find((p) => p.day === day(30))?.rated).toBe(false); + expect(load.recentRatedDays).toBe(40); + expect(load.recentDays).toBe(42); + // Without the activity data the same rows would hold from day 10 on. + const blind = computeTrainingLoad(rows, day(59)); + if (blind.status !== "ok") throw new Error("expected ok"); + expect(blind.asOf).toBe(day(9)); + expect(load.ctl).toBeLessThan(blind.ctl); + }); + + it("runs the series to the end and counts a trailing gap as unrated", () => { + const rows = Array.from({ length: 40 }, (_, i) => ({ + day: day(i), + dayTrimp: 60, + })); + const load = computeTrainingLoad(rows, day(119)); + if (load.status !== "ok") throw new Error("expected ok"); + expect(load.asOf).toBe(day(39)); + expect(load.series.at(-1)?.day).toBe(day(119)); + expect(load.recentRatedDays).toBe(0); + expect(load.recentDays).toBe(42); + expect(load.daysCovered).toBe(120); }); it("answers insufficient without impulse or with too little history", () => { @@ -113,21 +177,42 @@ describe("computeTrainingLoad", () => { }); describe("readTrainingLoad", () => { - it("describes the day the strain run scored", async () => { + it("describes the day the strain run scored and reads activity days as rest", async () => { const { vi } = await import("vitest"); vi.resetModules(); - const findMany = vi.fn(async () => []); + const now = new Date("2026-10-21T01:30:00.000Z"); + const { scoreDayKey } = await import("@/lib/insights/score-row"); + const until = scoreDayKey(now); + const first = shiftDateKey(until, -59); + const cache = Array.from({ length: 10 }, (_, i) => ({ + day: shiftDateKey(first, i), + dayTrimp: i < 3 ? 80 : 0, + })); + const strainFindMany = vi.fn(async () => cache); + // Steps on every day after the stored rows; one workout on the last day. + const rollupFindMany = vi.fn(async () => + Array.from({ length: 50 }, (_, i) => ({ + bucketStart: new Date(`${shiftDateKey(first, 10 + i)}T00:00:00.000Z`), + })), + ); + const queryRaw = vi.fn(async () => [{ day: until }]); vi.doMock("@/lib/db", () => ({ - prisma: { strainTrimpCache: { findMany } }, + prisma: { + strainTrimpCache: { findMany: strainFindMany }, + measurementRollup: { findMany: rollupFindMany }, + $queryRaw: queryRaw, + }, })); 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 { + const load = await readTrainingLoad("u1", now); + const args = (strainFindMany.mock.calls[0] as unknown[])[0] as { where: { day: { lte: string } }; }; - expect(args.where.day.lte).toBe(scoreDayKey(now)); + expect(args.where.day.lte).toBe(until); + if (load.status !== "ok") throw new Error("expected ok"); + expect(load.series.at(-1)).toMatchObject({ day: until, rated: false }); + expect(load.asOf).toBe(shiftDateKey(until, -1)); + expect(load.recentRatedDays).toBe(41); vi.doUnmock("@/lib/db"); }); }); diff --git a/src/lib/insights/training-load.ts b/src/lib/insights/training-load.ts index 7c56444fb..3bf7b8fa4 100644 --- a/src/lib/insights/training-load.ts +++ b/src/lib/insights/training-load.ts @@ -4,14 +4,23 @@ * * The impulse is the Banister TRIMP the nightly strain run already writes to * `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): + * training. The strain run writes no row on a day with neither a scorable + * workout nor active energy, so a missing row alone does not tell rest from + * absence. A day without a row is therefore classified from the underlying + * data: + * + * - a workout on the day: training that could not be scored, not rated; + * - steps, heart rate or active energy on the day: the person wore or + * carried a device and did not train, rest (impulse 0); + * - none of these: nothing reached the server, not rated. + * + * An unrated day holds the averages instead of pulling them toward zero. + * The series runs to the day the strain run scores (yesterday in UTC), so a + * stretch without data after the last rated day shows as unrated days and + * in `recentRatedDays`; `asOf` names the last rated day. Over the impulse 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 @@ -21,8 +30,9 @@ * 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. + * no performance prediction and no advice. Pure apart from the reads. */ +import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { shiftDateKey } from "@/lib/tz/format"; @@ -42,14 +52,17 @@ export interface TrainingLoadPoint { ctl: number; atl: number; tsb: number; - /** False for a day without a stored impulse: the averages held through it. */ + /** + * False for a day with neither a stored impulse nor any activity data, or + * with a workout that could not be scored: the averages held through it. + */ rated: boolean; } export type TrainingLoad = | { status: "ok"; - /** The last scored day the figures describe (UTC day key). */ + /** The last rated day the figures describe (UTC day key). */ asOf: string; ctl: number; atl: number; @@ -57,7 +70,11 @@ 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. */ + /** + * Rated days (a stored impulse, or activity data without a workout) of + * the trailing {@link CTL_DAYS} days up to yesterday, or fewer when + * covered less. + */ recentRatedDays: number; /** The trailing window `recentRatedDays` counts over. */ recentDays: number; @@ -74,13 +91,46 @@ export type TrainingLoad = const round1 = (x: number) => Math.round(x * 10) / 10; /** - * 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. + * What the read knows about the days without a stored impulse: the UTC days + * with any activity data (steps, heart rate, active energy) and the UTC days + * with a workout. + */ +export interface TrainingLoadActivity { + dataDays: ReadonlySet; + workoutDays: ReadonlySet; +} + +const NO_ACTIVITY: TrainingLoadActivity = { + dataDays: new Set(), + workoutDays: new Set(), +}; + +/** + * The impulse of one day, or `undefined` when the day is not rated: the + * stored impulse when there is one; otherwise rest (0) on a day with + * activity data and no workout, and not rated on a day with a workout that + * was never scored or with no data at all. + */ +function dayLoad( + day: string, + byDay: ReadonlyMap, + activity: TrainingLoadActivity, +): number | undefined { + const stored = byDay.get(day); + if (stored !== undefined) return stored; + if (activity.workoutDays.has(day)) return undefined; + return activity.dataDays.has(day) ? 0 : undefined; +} + +/** + * Run the model from the first stored day through `until`. A day is rated + * as {@link dayLoad} says; an unrated day holds the averages. Rows after + * `until` are ignored. */ export function computeTrainingLoad( rows: ReadonlyArray<{ day: string; dayTrimp: number }>, until: string, + activity: TrainingLoadActivity = NO_ACTIVITY, ): TrainingLoad { const byDay = new Map(); for (const row of rows) { @@ -98,17 +148,22 @@ 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)) { + let asOf = days[0]; + let asOfCtl = 0; + let asOfAtl = 0; + for (let day = days[0]; day <= until; day = shiftDateKey(day, 1)) { const tsb = ctl - atl; - const load = byDay.get(day); + const load = dayLoad(day, byDay, activity); if (load !== undefined) { ctl += (load - ctl) / CTL_DAYS; atl += (load - atl) / ATL_DAYS; + asOf = day; + asOfCtl = ctl; + asOfAtl = atl; } daysCovered += 1; series.push({ day, ctl, atl, tsb, rated: load !== undefined }); @@ -131,9 +186,9 @@ export function computeTrainingLoad( return { status: "ok", asOf, - ctl: round1(ctl), - atl: round1(atl), - tsb: round1(ctl - atl), + ctl: round1(asOfCtl), + atl: round1(asOfAtl), + tsb: round1(asOfCtl - asOfAtl), daysCovered, trainingDays, recentRatedDays, @@ -149,9 +204,17 @@ export function computeTrainingLoad( }; } +/** The measurement types whose presence on a day says a device was worn or carried. */ +export const TRAINING_LOAD_PRESENCE_TYPES: MeasurementType[] = [ + "ACTIVITY_STEPS", + "PULSE", + "ACTIVE_ENERGY_BURNED", +]; + /** - * 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). + * Read the stored impulse, the days with activity data (from the daily + * rollups) and the days with a workout, and run the model up to the day the + * strain run scores (yesterday in UTC). */ export async function readTrainingLoad( userId: string, @@ -162,14 +225,44 @@ export async function readTrainingLoad( // 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 until = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10); - const rows = await prisma.strainTrimpCache.findMany({ - where: { - userId, - day: { gte: shiftDateKey(until, -TRAINING_LOAD_READ_DAYS), lte: until }, - }, - orderBy: { day: "asc" }, - take: TRAINING_LOAD_READ_DAYS + 1, - select: { day: true, dayTrimp: true }, + const from = shiftDateKey(until, -TRAINING_LOAD_READ_DAYS); + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day + const fromInstant = new Date(`${from}T00:00:00.000Z`); + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day + const endInstant = new Date(`${shiftDateKey(until, 1)}T00:00:00.000Z`); + const [rows, presence, workoutDays] = await Promise.all([ + prisma.strainTrimpCache.findMany({ + where: { userId, day: { gte: from, lte: until } }, + orderBy: { day: "asc" }, + take: TRAINING_LOAD_READ_DAYS + 1, + select: { day: true, dayTrimp: true }, + }), + // Daily rollups bucket on the UTC day, the same day the strain cache + // keys on. + prisma.measurementRollup.findMany({ + where: { + userId, + granularity: "DAY", + type: { in: TRAINING_LOAD_PRESENCE_TYPES }, + bucketStart: { gte: fromInstant, lt: endInstant }, + count: { gt: 0 }, + }, + distinct: ["bucketStart"], + select: { bucketStart: true }, + }), + // One row per UTC day with a workout; `started_at` is stored as UTC. + prisma.$queryRaw>` + SELECT DISTINCT to_char("started_at", 'YYYY-MM-DD') AS day + FROM "workouts" + WHERE "user_id" = ${userId} + AND "started_at" >= ${fromInstant} AND "started_at" < ${endInstant} + `, + ]); + return computeTrainingLoad(rows, until, { + dataDays: new Set( + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: a DAY rollup bucket starts at UTC midnight + presence.map((r) => r.bucketStart.toISOString().slice(0, 10)), + ), + workoutDays: new Set(workoutDays.map((r) => r.day)), }); - return computeTrainingLoad(rows, until); } diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index a64febd99..818f6080c 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -118,7 +118,7 @@ const trainingLoadPoint = z.object({ 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.", + "False for a day that could not be rated: no stored impulse and no activity data (steps, heart rate, active energy), or a workout that was never scored. The averages held through it rather than counting it as rest. A day with activity data and no workout is rest (impulse 0).", ), }); @@ -129,7 +129,7 @@ const trainingLoad = z asOf: z .string() .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.", + "The last rated UTC day, at most yesterday; the figures are those of this day. The series runs on to yesterday, so a later stretch without data shows as unrated days.", ), ctl: z.number().describe("Fitness: 42-day exponentially weighted TRIMP."), atl: z.number().describe("Fatigue: 7-day exponentially weighted TRIMP."), @@ -140,7 +140,7 @@ const trainingLoad = z .number() .int() .describe( - "Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse.", + "Rated days of the trailing `recentDays` (42 up to yesterday, or fewer when covered less): a stored impulse, or activity data without a workout.", ), recentDays: z.number().int(), warmingUp: z @@ -162,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 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.", + "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day with activity data but no training is 0, a day with no data or an unscored workout 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