mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge branch 'feat/v144-analysis' into release/v1.44.0
This commit is contained in:
commit
67d04630c3
74 files changed
+4633
-120
No files matched your search
@@ -155,6 +155,12 @@ export function HealthStatusCard({
|
||||
before: metricValue(s.metric, s.beforeMean),
|
||||
after: metricValue(s.metric, s.afterMean),
|
||||
})}
|
||||
{s.beforeDays !== undefined && s.afterDays !== undefined
|
||||
? ` ${t("insights.healthStatus.shiftDays", {
|
||||
before: s.beforeDays,
|
||||
after: s.afterDays,
|
||||
})}`
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,8 +80,28 @@ const CHANNEL_LABEL_KEY: Record<string, string> = {
|
||||
// v1.43 — the Dawarich movement channels.
|
||||
MOVEMENT_AWAY: "environment.movement.awayFromHome",
|
||||
MOVEMENT_DISTANCE: "environment.movement.distance",
|
||||
// v1.44 — the extended channel families.
|
||||
HRV_RMSSD: "measurements.typeHrvRmssd",
|
||||
EXERCISE_MINUTES: "measurements.typeExerciseMinutes",
|
||||
STAND_MINUTES: "measurements.typeStandMinutes",
|
||||
MINDFUL_MINUTES: "measurements.typeMindfulMinutes",
|
||||
HYDRATION: "nutrients.names.water",
|
||||
CAFFEINE: "nutrients.names.caffeine",
|
||||
ALCOHOL: "insights.mood.discovery.channel.ALCOHOL",
|
||||
WORKOUT_MINUTES: "insights.mood.discovery.channel.WORKOUT_MINUTES",
|
||||
TRAINING_LOAD: "insights.mood.discovery.channel.TRAINING_LOAD",
|
||||
};
|
||||
|
||||
/** A micronutrient channel (`NUTRIENT:<code>`) reads under its catalog name. */
|
||||
const NUTRIENT_PREFIX = "NUTRIENT:";
|
||||
|
||||
function channelLabelKey(channelKey: string): string {
|
||||
if (channelKey.startsWith(NUTRIENT_PREFIX)) {
|
||||
return `nutrients.names.${channelKey.slice(NUTRIENT_PREFIX.length)}`;
|
||||
}
|
||||
return CHANNEL_LABEL_KEY[channelKey] ?? channelKey;
|
||||
}
|
||||
|
||||
/** Keep only the discovered pairs that involve the mood channel. */
|
||||
export function moodPairsOf(
|
||||
discovered: DiscoveredCorrelation[],
|
||||
@@ -131,8 +151,7 @@ export function MoodDiscoveredRelations({
|
||||
const dynamicLabel = moodIsOutcome
|
||||
? pair.behaviourLabel
|
||||
: pair.outcomeLabel;
|
||||
const factorLabel =
|
||||
dynamicLabel ?? t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey);
|
||||
const factorLabel = dynamicLabel ?? t(channelLabelKey(channelKey));
|
||||
if (
|
||||
patternId &&
|
||||
dismissal.isDismissed(patternId, pair.dismissed === true)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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 { LabTrendCard } from "../lab-trend-card";
|
||||
import type { BiomarkerTrendDto } from "../types";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
const OK: BiomarkerTrendDto = {
|
||||
status: "ok",
|
||||
n: 4,
|
||||
firstAt: "2023-01-10",
|
||||
lastAt: "2026-01-10",
|
||||
spanDays: 1096,
|
||||
slopePerYear: 12.5,
|
||||
slopeCi95: [4.25, 20.75],
|
||||
pValue: 0.02,
|
||||
fittedNow: 130,
|
||||
asOf: "2026-10-21",
|
||||
projection: "exits",
|
||||
exitDate: "2027-11-02",
|
||||
exitBound: "high",
|
||||
};
|
||||
|
||||
function render(
|
||||
trend: BiomarkerTrendDto | undefined,
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<LabTrendCard trend={trend} unit="mg/dL" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LabTrendCard>", () => {
|
||||
it("words the server's slope and labels the exit as a projection", () => {
|
||||
const html = render(OK);
|
||||
expect(html).toContain("Rising by about 13 mg/dL a year across 4 readings");
|
||||
expect(html).toContain('data-projection="exits"');
|
||||
expect(html).toContain("upper end of your reference range");
|
||||
expect(html).toContain("projection, not a forecast");
|
||||
expect(html).toContain("4.25 to 20.75 mg/dL");
|
||||
});
|
||||
|
||||
it("speaks German with du", () => {
|
||||
const html = render(OK, "de");
|
||||
expect(html).toContain("deines Referenzbereichs");
|
||||
expect(html).toContain("Fortschreibung, keine Vorhersage");
|
||||
});
|
||||
|
||||
it("draws no projection sentence without a clear trend, and nothing when insufficient", () => {
|
||||
const html = render({
|
||||
...OK,
|
||||
projection: "no_clear_trend",
|
||||
exitDate: null,
|
||||
exitBound: null,
|
||||
});
|
||||
expect(html).toContain('data-projection="no_clear_trend"');
|
||||
expect(html).not.toContain("forecast");
|
||||
expect(
|
||||
render({
|
||||
status: "insufficient",
|
||||
reason: "too_few_values",
|
||||
n: 2,
|
||||
spanDays: 40,
|
||||
}),
|
||||
).toBe("");
|
||||
expect(render(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,7 @@ import { importWithRetry } from "@/lib/retry-import";
|
||||
import { BiomarkerAliases } from "./biomarker-aliases";
|
||||
import { BiomarkerForm } from "./biomarker-form";
|
||||
import { LabForm } from "./lab-form";
|
||||
import { LabTrendCard } from "./lab-trend-card";
|
||||
import type {
|
||||
BiomarkerDto,
|
||||
LabResultDto,
|
||||
@@ -505,6 +506,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<LabTrendCard
|
||||
trend={marker?.trend}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { TrendingUp } 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 { BiomarkerTrendDto } from "./types";
|
||||
|
||||
/**
|
||||
* v1.44 — the marker's slope over all its readings, and where that straight
|
||||
* line would leave the reference band.
|
||||
*
|
||||
* Server-authoritative: every number is the route's (`computeLabTrend`); this
|
||||
* only words it. Renders nothing below three readings over a year. The exit
|
||||
* sentence names itself a projection, never a forecast.
|
||||
*/
|
||||
export function LabTrendCard({
|
||||
trend,
|
||||
unit,
|
||||
}: {
|
||||
trend: BiomarkerTrendDto | undefined;
|
||||
unit: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
if (!trend || trend.status !== "ok") return null;
|
||||
|
||||
const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2;
|
||||
const per = (v: number) => fmt.number(Math.abs(v), digits);
|
||||
const direction =
|
||||
trend.slopePerYear > 0
|
||||
? "rising"
|
||||
: trend.slopePerYear < 0
|
||||
? "falling"
|
||||
: "flat";
|
||||
const slopeLine = t(`labs.trend.slope.${direction}`, {
|
||||
amount: per(trend.slopePerYear),
|
||||
unit,
|
||||
n: trend.n,
|
||||
since: fmt.dateShortSmartCalendar(trend.firstAt),
|
||||
});
|
||||
const projectionLine =
|
||||
trend.projection === "exits" && trend.exitDate
|
||||
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
|
||||
date: fmt.dateShortSmartCalendar(trend.exitDate),
|
||||
})
|
||||
: trend.projection === "no_band"
|
||||
? null
|
||||
: t(`labs.trend.projection.${trend.projection}`);
|
||||
|
||||
return (
|
||||
<Card data-slot="lab-trend">
|
||||
<CardHeader>
|
||||
<TileHeader icon={TrendingUp} title={t("labs.trend.title")} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<p className="text-sm" data-slot="lab-trend-slope">
|
||||
{slopeLine}
|
||||
</p>
|
||||
{projectionLine ? (
|
||||
<p
|
||||
className="text-sm"
|
||||
data-slot="lab-trend-projection"
|
||||
data-projection={trend.projection}
|
||||
>
|
||||
{projectionLine}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.trend.interval", {
|
||||
lo: fmt.number(trend.slopeCi95[0], 2),
|
||||
hi: fmt.number(trend.slopeCi95[1], 2),
|
||||
unit,
|
||||
})}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -108,8 +108,40 @@ export interface BiomarkerDto {
|
||||
hidden: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** v1.44 — the detail read's trend line; absent on the list and older servers. */
|
||||
trend?: BiomarkerTrendDto;
|
||||
}
|
||||
|
||||
/** v1.44 — `GET /api/biomarkers/{id}` `trend` (`computeLabTrend`). */
|
||||
export type BiomarkerTrendDto =
|
||||
| {
|
||||
status: "ok";
|
||||
n: number;
|
||||
firstAt: string;
|
||||
lastAt: string;
|
||||
spanDays: number;
|
||||
slopePerYear: number;
|
||||
slopeCi95: [number, number];
|
||||
pValue: number;
|
||||
fittedNow: number;
|
||||
asOf: string;
|
||||
projection:
|
||||
| "exits"
|
||||
| "beyond_horizon"
|
||||
| "no_exit"
|
||||
| "already_outside"
|
||||
| "no_clear_trend"
|
||||
| "no_band";
|
||||
exitDate: string | null;
|
||||
exitBound: "low" | "high" | null;
|
||||
}
|
||||
| {
|
||||
status: "insufficient";
|
||||
reason: "too_few_values" | "span_too_short";
|
||||
n: number;
|
||||
spanDays: number;
|
||||
};
|
||||
|
||||
export interface BiomarkerListResponse {
|
||||
biomarkers: BiomarkerDto[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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 { AssessmentTrendNote } from "../assessment-trend-note";
|
||||
import type { AssessmentTrendDto } from "../types";
|
||||
|
||||
primeLocales("de");
|
||||
|
||||
const TREND: AssessmentTrendDto = {
|
||||
instrument: "PHQ9",
|
||||
n: 3,
|
||||
threshold: 5,
|
||||
higherIsBetter: false,
|
||||
first: { score: 16, takenAt: "2026-01-10T09:00:00.000Z" },
|
||||
previous: { score: 12, takenAt: "2026-03-15T09:00:00.000Z" },
|
||||
latest: { score: 9, takenAt: "2026-06-01T09:00:00.000Z" },
|
||||
changeFromPrevious: -3,
|
||||
changeFromFirst: -7,
|
||||
sincePrevious: "within_threshold",
|
||||
sinceFirst: "improved",
|
||||
};
|
||||
|
||||
const render = (trend: AssessmentTrendDto, locale: "en" | "de" = "en") =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<AssessmentTrendNote trend={trend} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("<AssessmentTrendNote>", () => {
|
||||
it("states each change and whether it reaches the threshold", () => {
|
||||
const html = render(TREND);
|
||||
expect(html).toContain("-3 points");
|
||||
expect(html).toContain("smaller than the 5-point change");
|
||||
expect(html).toContain("-7 points");
|
||||
expect(html).toContain('data-verdict="improved"');
|
||||
});
|
||||
|
||||
it("shows a rise with its sign, in German with du", () => {
|
||||
const html = render(
|
||||
{ ...TREND, n: 2, changeFromPrevious: 6, sincePrevious: "worsened" },
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Seit deinem vorigen Check-in");
|
||||
expect(html).toContain("+6 Punkte");
|
||||
expect(html).not.toContain("ersten");
|
||||
});
|
||||
|
||||
it("is silent with a single check-in", () => {
|
||||
expect(
|
||||
render({
|
||||
...TREND,
|
||||
n: 1,
|
||||
previous: null,
|
||||
changeFromPrevious: null,
|
||||
changeFromFirst: null,
|
||||
sincePrevious: null,
|
||||
sinceFirst: null,
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { AssessmentTrendDto } from "./types";
|
||||
|
||||
/**
|
||||
* v1.44 — how the latest score compares with the previous check-in (and,
|
||||
* from the third, with the first), against the change size studies treat as
|
||||
* meaningful for this instrument. Every number and verdict is the server's;
|
||||
* the wording stays neutral: a change, its size, and whether it reaches the
|
||||
* threshold. No band words, no advice.
|
||||
*/
|
||||
export function AssessmentTrendNote({ trend }: { trend: AssessmentTrendDto }) {
|
||||
const { t } = useTranslations();
|
||||
const { date: formatDate, number } = useFormatters();
|
||||
if (trend.previous === null || trend.changeFromPrevious === null) return null;
|
||||
|
||||
const signed = (delta: number) =>
|
||||
delta > 0 ? `+${number(delta, 0)}` : number(delta, 0);
|
||||
const line = (
|
||||
key: "sincePrevious" | "sinceFirst",
|
||||
at: string,
|
||||
delta: number,
|
||||
verdict: NonNullable<AssessmentTrendDto["sincePrevious"]>,
|
||||
) =>
|
||||
`${t(`mentalHealth.trend.${key}`, {
|
||||
date: formatDate(at),
|
||||
change: signed(delta),
|
||||
})} ${t(`mentalHealth.trend.verdict.${verdict}`, {
|
||||
threshold: trend.threshold,
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-sm" data-slot="assessment-trend">
|
||||
{trend.sincePrevious ? (
|
||||
<p data-verdict={trend.sincePrevious}>
|
||||
{line(
|
||||
"sincePrevious",
|
||||
trend.previous.takenAt,
|
||||
trend.changeFromPrevious,
|
||||
trend.sincePrevious,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{trend.n >= 3 && trend.sinceFirst && trend.changeFromFirst !== null ? (
|
||||
<p data-verdict={trend.sinceFirst}>
|
||||
{line(
|
||||
"sinceFirst",
|
||||
trend.first.takenAt,
|
||||
trend.changeFromFirst,
|
||||
trend.sinceFirst,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,16 +21,20 @@ import { relativeCalendarDate } from "@/lib/i18n/relative-time";
|
||||
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
|
||||
|
||||
import { AssessmentHistory } from "./assessment-history";
|
||||
import type { AssessmentRow, InstrumentId } from "./types";
|
||||
import type { AssessmentRow, AssessmentTrendDto, InstrumentId } from "./types";
|
||||
import { AssessmentTrendNote } from "./assessment-trend-note";
|
||||
|
||||
export function InstrumentDetail({
|
||||
instrument,
|
||||
rows,
|
||||
trend,
|
||||
onStart,
|
||||
}: {
|
||||
instrument: InstrumentId;
|
||||
/** ALL assessments; the pinned history filters to this instrument. */
|
||||
rows: AssessmentRow[];
|
||||
/** v1.44 — the server's change against the meaningful-change threshold. */
|
||||
trend?: AssessmentTrendDto;
|
||||
/** Start a check-in for this instrument (closes the host sheet). */
|
||||
onStart: () => void;
|
||||
}) {
|
||||
@@ -75,6 +79,8 @@ export function InstrumentDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trend ? <AssessmentTrendNote trend={trend} /> : null}
|
||||
|
||||
{/* The Start action stays reachable here too — the detail is a calm
|
||||
reading surface, not a dead end. */}
|
||||
{canManageMind && (
|
||||
|
||||
@@ -45,6 +45,7 @@ import { InstrumentCard } from "./instrument-card";
|
||||
import { InstrumentDetail } from "./instrument-detail";
|
||||
import type {
|
||||
AssessmentRow,
|
||||
AssessmentTrendDto,
|
||||
CreateResponse,
|
||||
InstrumentId,
|
||||
Phase,
|
||||
@@ -81,9 +82,10 @@ export function MentalWellbeing() {
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
queryFn: () =>
|
||||
apiGetList<{ assessments: AssessmentRow[] }>(
|
||||
"/api/mental-health/assessments",
|
||||
),
|
||||
apiGetList<{
|
||||
assessments: AssessmentRow[];
|
||||
trends?: AssessmentTrendDto[];
|
||||
}>("/api/mental-health/assessments"),
|
||||
});
|
||||
const history = historyPage?.data;
|
||||
|
||||
@@ -229,6 +231,9 @@ export function MentalWellbeing() {
|
||||
<InstrumentDetail
|
||||
instrument={detailInstrument}
|
||||
rows={history?.assessments ?? []}
|
||||
trend={history?.trends?.find(
|
||||
(trend) => trend.instrument === detailInstrument,
|
||||
)}
|
||||
onStart={() => begin(detailInstrument)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -48,3 +48,18 @@ export interface CreateResponse {
|
||||
actionThreshold: number;
|
||||
crisis: CrisisSet | null;
|
||||
}
|
||||
|
||||
/** v1.44 — `GET /api/mental-health/assessments` `trends[]` (`computeAssessmentTrends`). */
|
||||
export interface AssessmentTrendDto {
|
||||
instrument: "PHQ9" | "GAD7" | "WHO5";
|
||||
n: number;
|
||||
threshold: number;
|
||||
higherIsBetter: boolean;
|
||||
first: { score: number; takenAt: string };
|
||||
previous: { score: number; takenAt: string } | null;
|
||||
latest: { score: number; takenAt: string };
|
||||
changeFromPrevious: number | null;
|
||||
changeFromFirst: number | null;
|
||||
sincePrevious: "improved" | "worsened" | "within_threshold" | null;
|
||||
sinceFirst: "improved" | "worsened" | "within_threshold" | null;
|
||||
}
|
||||
@@ -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