mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user