mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
5c64192afc
commit
9be720146c
19 files changed
+651
-1
No files matched your search
@@ -48526,6 +48526,10 @@ components:
|
||||
- offset
|
||||
- droppedDuplicates
|
||||
additionalProperties: false
|
||||
trainingLoad:
|
||||
description: v1.44 — on the first page (`offset=0`) only, account-wide whatever the filter. Absent on later pages and
|
||||
older servers.
|
||||
$ref: "#/components/schemas/WorkoutTrainingLoad"
|
||||
required:
|
||||
- workouts
|
||||
- meta
|
||||
@@ -48602,6 +48606,94 @@ components:
|
||||
- hasRoute
|
||||
- hasHrSeries
|
||||
additionalProperties: false
|
||||
WorkoutTrainingLoad:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
const: ok
|
||||
asOf:
|
||||
type: string
|
||||
description: The last scored UTC day the figures describe.
|
||||
ctl:
|
||||
type: number
|
||||
description: "Fitness: 42-day exponentially weighted TRIMP."
|
||||
atl:
|
||||
type: number
|
||||
description: "Fatigue: 7-day exponentially weighted TRIMP."
|
||||
tsb:
|
||||
type: number
|
||||
description: "Form: fitness minus fatigue at `asOf`."
|
||||
daysCovered:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
trainingDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
warmingUp:
|
||||
type: boolean
|
||||
description: "True below 42 covered days: both averages start at zero."
|
||||
series:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
day:
|
||||
type: string
|
||||
ctl:
|
||||
type: number
|
||||
atl:
|
||||
type: number
|
||||
tsb:
|
||||
type: number
|
||||
required:
|
||||
- day
|
||||
- ctl
|
||||
- atl
|
||||
- tsb
|
||||
additionalProperties: false
|
||||
description: The trailing 90 days, oldest first; `tsb` is form going into the day.
|
||||
required:
|
||||
- status
|
||||
- asOf
|
||||
- ctl
|
||||
- atl
|
||||
- tsb
|
||||
- daysCovered
|
||||
- trainingDays
|
||||
- warmingUp
|
||||
- series
|
||||
additionalProperties: false
|
||||
- type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
const: insufficient
|
||||
reason:
|
||||
type: string
|
||||
enum:
|
||||
- no_impulse_history
|
||||
- too_little_history
|
||||
daysCovered:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
trainingDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- status
|
||||
- reason
|
||||
- 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."
|
||||
GetWorkoutResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -3833,6 +3833,14 @@
|
||||
"editTitle": "Training bearbeiten",
|
||||
"editAction": "Bearbeiten",
|
||||
"updated": "Training aktualisiert"
|
||||
},
|
||||
"load": {
|
||||
"title": "Trainingslast",
|
||||
"fitness": "Fitness",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
|
||||
@@ -3833,6 +3833,14 @@
|
||||
"editTitle": "Edit workout",
|
||||
"editAction": "Edit",
|
||||
"updated": "Workout updated"
|
||||
},
|
||||
"load": {
|
||||
"title": "Training load",
|
||||
"fitness": "Fitness",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
|
||||
@@ -3833,6 +3833,14 @@
|
||||
"editTitle": "Editar entrenamiento",
|
||||
"editAction": "Editar",
|
||||
"updated": "Entrenamiento actualizado"
|
||||
},
|
||||
"load": {
|
||||
"title": "Carga de entrenamiento",
|
||||
"fitness": "Forma de base",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
|
||||
@@ -3833,6 +3833,14 @@
|
||||
"editTitle": "Modifier l’entraînement",
|
||||
"editAction": "Modifier",
|
||||
"updated": "Entraînement mis à jour"
|
||||
},
|
||||
"load": {
|
||||
"title": "Charge d'entraînement",
|
||||
"fitness": "Condition",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
|
||||
@@ -3833,6 +3833,14 @@
|
||||
"editTitle": "Modifica allenamento",
|
||||
"editAction": "Modifica",
|
||||
"updated": "Allenamento aggiornato"
|
||||
},
|
||||
"load": {
|
||||
"title": "Carico di allenamento",
|
||||
"fitness": "Forma di base",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
|
||||
@@ -3833,6 +3833,14 @@
|
||||
"editTitle": "운동 편집",
|
||||
"editAction": "편집",
|
||||
"updated": "운동을 업데이트했습니다"
|
||||
},
|
||||
"load": {
|
||||
"title": "훈련 부하",
|
||||
"fitness": "체력",
|
||||
"fatigue": "피로",
|
||||
"form": "컨디션",
|
||||
"explainer": "체력은 심박 기반 훈련 자극을 약 6주 동안, 피로는 약 1주 동안 평균한 값이고, 컨디션은 그 차이입니다. 이 숫자는 다른 사람이나 다른 앱이 아니라 내 과거와 비교하는 값입니다.",
|
||||
"warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다."
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
|
||||
@@ -3833,6 +3833,14 @@
|
||||
"editTitle": "Workout bewerken",
|
||||
"editAction": "Bewerken",
|
||||
"updated": "Workout bijgewerkt"
|
||||
},
|
||||
"load": {
|
||||
"title": "Trainingsbelasting",
|
||||
"fitness": "Fitheid",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
|
||||
@@ -3833,6 +3833,14 @@
|
||||
"editTitle": "Edytuj trening",
|
||||
"editAction": "Edytuj",
|
||||
"updated": "Trening zaktualizowany"
|
||||
},
|
||||
"load": {
|
||||
"title": "Obciążenie treningowe",
|
||||
"fitness": "Wydolność",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"bloodGlucose": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user