diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 1b7a1c041..45452791c 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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:
diff --git a/messages/de.json b/messages/de.json
index 7fa10ef44..1862f7fce 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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": {
diff --git a/messages/en.json b/messages/en.json
index 3e8a2fd08..31c814e13 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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": {
diff --git a/messages/es.json b/messages/es.json
index 74d46bd52..b8f865a17 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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": {
diff --git a/messages/fr.json b/messages/fr.json
index b5fe83be3..52a6d0729 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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": {
diff --git a/messages/it.json b/messages/it.json
index d4b271a51..69ab3a6be 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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": {
diff --git a/messages/ko.json b/messages/ko.json
index 80faf9d15..ffaa699a6 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -3833,6 +3833,14 @@
"editTitle": "운동 편집",
"editAction": "편집",
"updated": "운동을 업데이트했습니다"
+ },
+ "load": {
+ "title": "훈련 부하",
+ "fitness": "체력",
+ "fatigue": "피로",
+ "form": "컨디션",
+ "explainer": "체력은 심박 기반 훈련 자극을 약 6주 동안, 피로는 약 1주 동안 평균한 값이고, 컨디션은 그 차이입니다. 이 숫자는 다른 사람이나 다른 앱이 아니라 내 과거와 비교하는 값입니다.",
+ "warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다."
}
},
"bloodGlucose": {
diff --git a/messages/nl.json b/messages/nl.json
index e4bf20265..c66ceb99d 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -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": {
diff --git a/messages/pl.json b/messages/pl.json
index 8913a25f1..a7e53374e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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": {
diff --git a/src/__tests__/analysis-depth-no-ai-guard.test.ts b/src/__tests__/analysis-depth-no-ai-guard.test.ts
index b3b9e4067..11ed68149 100644
--- a/src/__tests__/analysis-depth-no-ai-guard.test.ts
+++ b/src/__tests__/analysis-depth-no-ai-guard.test.ts
@@ -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. */
diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts
index b5f0f181f..82906eb8e 100644
--- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts
+++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts
@@ -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();
+ });
+});
diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx
index c307d65af..b978cabee 100644
--- a/src/app/insights/workouts/page-client.tsx
+++ b/src/app/insights/workouts/page-client.tsx
@@ -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() {
})}
) : null}
+
+ renderToStaticMarkup(
+ {t("insights.workouts.load.explainer")}
+ {t("insights.workouts.load.warmingUp", {
+ days: trainingLoad.daysCovered,
+ })}
+
+ {stats.map((stat) => (
+
+