fix(workouts): read a day with activity data and no workout as rest

The strain run writes no impulse row on a day without a scorable workout
or active energy, so a rest day for someone without daily active energy
was never rated: fitness and fatigue held for months instead of
decaying, and the figures named the last stored day while the coverage
count ended there too.

A day without a stored impulse is now classified from the underlying
data: a workout on the day leaves it unrated, steps, heart rate or
active energy without a workout make it rest, and no data at all leaves
it unrated. The series runs to the day the strain run scores, so a
stretch without data after the last rated day shows in the coverage
count, and the card names the last rated day when it lies further back
than the day before.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 00:25:29 +02:00
1 parent 0918451e02
commit 29d6029999
15 files changed
+286 -66

No files matched your search

@@ -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 () => []),
},
}));
@@ -44,7 +44,19 @@ describe("<TrainingLoadCard>", () => {
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", () => {
+18 -1
View File
@@ -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({
))}
</dl>
<p className="text-sm">{t("insights.workouts.load.explainer")}</p>
{stale ? (
<p
className="text-muted-foreground text-xs"
data-slot="training-load-as-of"
>
{t("insights.workouts.load.asOf", {
date: fmt.dateShortSmartCalendar(trainingLoad.asOf),
})}
</p>
) : null}
{trainingLoad.recentRatedDays < trainingLoad.recentDays ? (
<p
className="text-muted-foreground text-xs"
@@ -68,17 +68,81 @@ describe("computeTrainingLoad", () => {
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<string>();
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");
});
});
+125 -32
View File
@@ -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<string>;
workoutDays: ReadonlySet<string>;
}
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<string, number>,
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<string, number>();
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<Array<{ day: string }>>`
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);
}
+4 -4
View File
@@ -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