From db02eec2a563205c1e6725d38e56c0f1b19f06c1 Mon Sep 17 00:00:00 2001
From: Asher Cohen
Date: Sun, 2 Aug 2026 02:37:26 -0700
Subject: [PATCH 1/6] fix(training): lead with plain labels
Move model names and formulas into accessible details across web and mobile.\n\nCloses #2084
---
packages/mobile/app/(tabs)/strain.test.tsx | 40 +++++--
packages/mobile/app/(tabs)/strain.tsx | 15 ++-
.../TrainingDistributionCards.test.tsx | 83 +++++++++++--
.../components/TrainingDistributionCards.tsx | 112 ++++++++++--------
.../components/TrainingMethodDetails.tsx | 57 +++++++++
packages/training/src/terminology.ts | 74 ++++++++++++
.../ActivityVariabilityTable.test.tsx | 15 +++
.../components/ActivityVariabilityTable.tsx | 3 +-
.../src/components/EstimatedMaxChart.test.tsx | 10 ++
.../web/src/components/EstimatedMaxChart.tsx | 11 +-
.../GradeAdjustedPaceTable.test.tsx | 4 +
.../src/components/GradeAdjustedPaceTable.tsx | 7 ++
.../src/components/MethodExplanation.test.tsx | 25 ++--
.../web/src/components/MethodExplanation.tsx | 39 ++++--
.../PolarizationTrendChart.test.tsx | 12 +-
.../src/components/PolarizationTrendChart.tsx | 23 ++--
.../src/components/PowerCurveChart.test.tsx | 40 ++++---
.../web/src/components/PowerCurveChart.tsx | 41 ++++---
packages/web/src/components/StrainCard.tsx | 7 ++
.../components/TrainingInsightsPanel.test.tsx | 22 +++-
.../src/components/TrainingInsightsPanel.tsx | 13 +-
.../components/TrainingMonotonyChart.test.tsx | 16 +--
.../src/components/TrainingMonotonyChart.tsx | 36 ++++--
.../components/WorkloadRatioChart.test.tsx | 4 +-
.../web/src/components/WorkloadRatioChart.tsx | 13 +-
.../web/src/components/chart-options.test.ts | 14 +--
packages/web/src/lib/hikingPaceCopy.ts | 13 +-
packages/web/src/routes/training/cycling.tsx | 56 +++++----
.../web/src/routes/training/endurance.tsx | 10 +-
.../web/src/routes/training/hiking.test.tsx | 7 +-
.../training/range-plumbing.test-helper.tsx | 6 +-
.../web/src/routes/training/strength.lazy.tsx | 4 +-
32 files changed, 624 insertions(+), 208 deletions(-)
create mode 100644 packages/mobile/components/TrainingMethodDetails.tsx
create mode 100644 packages/training/src/terminology.ts
diff --git a/packages/mobile/app/(tabs)/strain.test.tsx b/packages/mobile/app/(tabs)/strain.test.tsx
index 93a34793d1..695a8f8853 100644
--- a/packages/mobile/app/(tabs)/strain.test.tsx
+++ b/packages/mobile/app/(tabs)/strain.test.tsx
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
+import { Alert } from "react-native";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockRouterPush = vi.fn();
@@ -303,7 +304,7 @@ describe("StrainScreen recent activity navigation", () => {
expect(mockProcessingStatusInvalidate).toHaveBeenCalledOnce();
});
- it("renders server-owned intensity and polarization models", async () => {
+ it("leads with plain labels while keeping server-owned model details accessible", async () => {
mockHrZonesState.data = {
maxHr: 190,
weeks: [],
@@ -368,14 +369,33 @@ describe("StrainScreen recent activity navigation", () => {
];
const { default: StrainScreen } = await import("./strain");
+ const alertSpy = vi.spyOn(Alert, "alert").mockImplementation(() => {});
render();
- expect(screen.getByText("Karvonen Intensity Distribution")).toBeTruthy();
- expect(screen.getByText("Mobile descriptive intensity explanation.")).toBeTruthy();
+ expect(screen.getByText("Heart-rate zone distribution")).toBeTruthy();
+ expect(
+ screen.getByText("Shows how recorded heart-rate time is distributed across effort zones."),
+ ).toBeTruthy();
+ expect(screen.queryByText("Mobile descriptive intensity explanation.")).toBeNull();
expect(screen.getByText("Not polarized")).toBeTruthy();
- expect(screen.getByText("Server says exactly 2.00 is not polarized.")).toBeTruthy();
- expect(screen.getByText("Training Monotony & Strain")).toBeTruthy();
- expect(screen.getByText("Mobile Foster formula.")).toBeTruthy();
+ expect(
+ screen.getByText("Shows the balance of easy, threshold, and high-intensity cycling."),
+ ).toBeTruthy();
+ expect(screen.queryByText("Server says exactly 2.00 is not polarized.")).toBeNull();
+ expect(screen.getByText("Training variety and total load")).toBeTruthy();
+ expect(screen.queryByText("Mobile Foster formula.")).toBeNull();
+
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "About How this is calculated for Easy-to-hard training balance",
+ }),
+ );
+ expect(alertSpy).toHaveBeenCalledWith(
+ "How this is calculated for Easy-to-hard training balance",
+ expect.stringContaining("Server says exactly 2.00 is not polarized."),
+ [{ text: "Close" }],
+ );
+ alertSpy.mockRestore();
});
it("renders intensity, polarization, and monotony query failures separately", async () => {
@@ -392,6 +412,8 @@ describe("StrainScreen recent activity navigation", () => {
expect(screen.getByText("Intensity distribution failed")).toBeTruthy();
expect(screen.getByText("Cycling polarization failed")).toBeTruthy();
expect(screen.getByText("Training monotony failed")).toBeTruthy();
+ expect(screen.getByText("Could not load easy-to-hard training balance")).toBeTruthy();
+ expect(screen.getByText("Could not load training variety and total load")).toBeTruthy();
expect(captureException).toHaveBeenCalledWith(mockHrZonesState.error);
expect(captureException).toHaveBeenCalledWith(mockPolarizationState.error);
expect(captureException).toHaveBeenCalledWith(mockMonotonyState.error);
@@ -437,8 +459,10 @@ describe("StrainScreen recent activity navigation", () => {
expect(screen.getByText("Intensity refresh failed")).toBeTruthy();
expect(screen.getByText("Polarization refresh failed")).toBeTruthy();
- expect(screen.getByText("Cached mobile intensity distribution.")).toBeTruthy();
- expect(screen.getByText("No cycling polarization data in this period")).toBeTruthy();
+ expect(
+ screen.getByText("Shows how recorded heart-rate time is distributed across effort zones."),
+ ).toBeTruthy();
+ expect(screen.getByText("No easy-to-hard training balance data in this period")).toBeTruthy();
});
it("keeps day selector visible while training data is loading", async () => {
diff --git a/packages/mobile/app/(tabs)/strain.tsx b/packages/mobile/app/(tabs)/strain.tsx
index be809a5876..200480b645 100644
--- a/packages/mobile/app/(tabs)/strain.tsx
+++ b/packages/mobile/app/(tabs)/strain.tsx
@@ -7,6 +7,7 @@ import {
} from "@dofek/format/format";
import { shouldShowBlockingLoading } from "@dofek/scoring/loading-policy";
import { aggregateWeeklyVolume, StrainScore } from "@dofek/scoring/scoring";
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import {
collapseWeeklyVolumeActivityTypes,
formatActivityTypeLabel,
@@ -398,7 +399,15 @@ export default function StrainScreen() {
{/* Workload breakdown */}
- Training Load
+
{formatTrainingLoad(acuteLoad)}
@@ -567,7 +576,7 @@ export default function StrainScreen() {
{polarizationQuery.isError ? (
) : polarizationQuery.isLoading && polarizationQuery.data == null ? (
@@ -577,7 +586,7 @@ export default function StrainScreen() {
{monotonyQuery.isError ? (
) : monotonyQuery.isLoading && monotonyQuery.data == null ? (
diff --git a/packages/mobile/components/TrainingDistributionCards.test.tsx b/packages/mobile/components/TrainingDistributionCards.test.tsx
index 550786f198..f5e69c2610 100644
--- a/packages/mobile/components/TrainingDistributionCards.test.tsx
+++ b/packages/mobile/components/TrainingDistributionCards.test.tsx
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
+import { Alert } from "react-native";
import { describe, expect, it, vi } from "vitest";
vi.mock("../lib/open-external-url", () => ({
@@ -11,7 +12,9 @@ import { openExternalUrl } from "../lib/open-external-url";
import { TrainingDistributionCards } from "./TrainingDistributionCards";
describe("TrainingDistributionCards", () => {
- it("renders server-computed Karvonen percentages without classifying them", () => {
+ it("leads with a plain description and keeps the server explanation accessible", () => {
+ const alertSpy = vi.spyOn(Alert, "alert").mockImplementation(() => {});
+
render(
{
/>,
);
- expect(screen.getByText("Karvonen Intensity Distribution")).toBeTruthy();
+ expect(screen.getByText("Heart-rate zone distribution")).toBeTruthy();
expect(screen.getByText("Recovery")).toBeTruthy();
expect(screen.getByText("25%")).toBeTruthy();
expect(screen.getByText("Aerobic")).toBeTruthy();
expect(screen.getByText("75%")).toBeTruthy();
- expect(screen.getByText("Server-provided descriptive Karvonen explanation.")).toBeTruthy();
+ expect(
+ screen.getByText("Shows how recorded heart-rate time is distributed across effort zones."),
+ ).toBeTruthy();
+ expect(screen.queryByText("Server-provided descriptive Karvonen explanation.")).toBeNull();
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "About How this is calculated for Heart-rate zone distribution",
+ }),
+ );
+ expect(alertSpy).toHaveBeenCalledWith(
+ "How this is calculated for Heart-rate zone distribution",
+ expect.stringContaining("Server-provided descriptive Karvonen explanation."),
+ [{ text: "Close" }],
+ );
+ alertSpy.mockRestore();
});
it("renders the exact Treff status and explanation returned by the server", () => {
+ const alertSpy = vi.spyOn(Alert, "alert").mockImplementation(() => {});
+
render(
{
/>,
);
- expect(screen.getByText("Cycling Polarization")).toBeTruthy();
+ expect(screen.getByText("Easy-to-hard training balance")).toBeTruthy();
expect(screen.getByText("Not polarized")).toBeTruthy();
- expect(screen.getByText("The exact 2.00 boundary is not polarized.")).toBeTruthy();
+ expect(
+ screen.getByText("Shows the balance of easy, threshold, and high-intensity cycling."),
+ ).toBeTruthy();
expect(screen.getByText("80% easy · 10% threshold · 10% high")).toBeTruthy();
+ expect(screen.getByText("Easy-to-hard balance 2.000")).toBeTruthy();
+ expect(screen.queryByText("The exact 2.00 boundary is not polarized.")).toBeNull();
+ expect(screen.queryByText("Server-provided Treff formula.")).toBeNull();
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "About How this is calculated for Easy-to-hard training balance",
+ }),
+ );
+ expect(alertSpy).toHaveBeenCalledWith(
+ "How this is calculated for Easy-to-hard training balance",
+ expect.stringContaining("Technical name: Polarization Index"),
+ [{ text: "Close" }],
+ );
+ expect(alertSpy).toHaveBeenCalledWith(
+ "How this is calculated for Easy-to-hard training balance",
+ expect.stringContaining("The exact 2.00 boundary is not polarized."),
+ [{ text: "Close" }],
+ );
+ alertSpy.mockRestore();
});
it("renders the server insufficient-data status instead of inventing a classification", () => {
+ const alertSpy = vi.spyOn(Alert, "alert").mockImplementation(() => {});
+
render(
{
);
expect(screen.getByText("Insufficient data")).toBeTruthy();
- expect(screen.getByText("Polarization needs time in every Treff zone.")).toBeTruthy();
+ expect(
+ screen.getByText("Shows the balance of easy, threshold, and high-intensity cycling."),
+ ).toBeTruthy();
+ expect(screen.queryByText("Polarization needs time in every Treff zone.")).toBeNull();
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "About How this is calculated for Easy-to-hard training balance",
+ }),
+ );
+ expect(alertSpy).toHaveBeenCalledWith(
+ "How this is calculated for Easy-to-hard training balance",
+ expect.stringContaining("Polarization needs time in every Treff zone."),
+ [{ text: "Close" }],
+ );
+ alertSpy.mockRestore();
});
it("renders server-computed monotony inputs, descriptive method, and source", () => {
@@ -157,15 +213,16 @@ describe("TrainingDistributionCards", () => {
/>,
);
- expect(screen.getByText("Training Monotony & Strain")).toBeTruthy();
- expect(screen.getByText("Monotony 2.18")).toBeTruthy();
- expect(screen.getByText("Strain 2460.0")).toBeTruthy();
+ expect(screen.getByText("Training variety and total load")).toBeTruthy();
+ expect(screen.getByText("Training variety 2.18")).toBeTruthy();
+ expect(screen.getByText("Weekly load strain 2460.0")).toBeTruthy();
+ expect(screen.getByText("Average daily load 161.14 · daily variation 73.92")).toBeTruthy();
+ expect(screen.queryByText(method.formula)).toBeNull();
expect(
- screen.getByText("Daily mean 161.14 · population standard deviation (SD) 73.92"),
+ screen.getByRole("button", {
+ name: "About How this is calculated for Training variety and total load",
+ }),
).toBeTruthy();
- expect(screen.getByText(method.formula)).toBeTruthy();
- expect(screen.getByText(method.calendar)).toBeTruthy();
- expect(screen.getByText(method.interpretation)).toBeTruthy();
fireEvent.click(screen.getByRole("link", { name: method.source.title }));
expect(openExternalUrl).toHaveBeenCalledWith(method.source.url, "training-monotony-source");
diff --git a/packages/mobile/components/TrainingDistributionCards.tsx b/packages/mobile/components/TrainingDistributionCards.tsx
index fc04e1ea0d..552d189b20 100644
--- a/packages/mobile/components/TrainingDistributionCards.tsx
+++ b/packages/mobile/components/TrainingDistributionCards.tsx
@@ -4,14 +4,15 @@ import {
formatIntensity,
formatNumber,
} from "@dofek/format/format";
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import type {
PolarizationTrendResult,
TrainingHrZonesResult,
TrainingMonotonyWeek,
} from "dofek-server/types";
-import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
-import { openExternalUrl } from "../lib/open-external-url";
+import { StyleSheet, Text, View } from "react-native";
import { colors } from "../theme";
+import { TrainingMethodDetails } from "./TrainingMethodDetails";
interface TrainingDistributionCardsProps {
intensityDistribution: TrainingHrZonesResult["intensityDistribution"] | null;
@@ -31,7 +32,9 @@ export function TrainingDistributionCards({
<>
{intensityDistribution && intensityDistribution.totalSeconds > 0 ? (
- Karvonen Intensity Distribution
+
+ {TRAINING_TERMINOLOGY.intensityDistribution.plainLabel}
+
{intensityDistribution.zones
.filter((zone) => zone.seconds > 0)
@@ -43,13 +46,24 @@ export function TrainingDistributionCards({
))}
- {intensityDistribution.explanation}
+
+ {TRAINING_TERMINOLOGY.intensityDistribution.plainDescription}
+
+
) : null}
{polarization ? (
- Cycling Polarization
+ {TRAINING_TERMINOLOGY.polarization.plainLabel}
{latestPolarizationWeek ? (
<>
@@ -59,7 +73,7 @@ export function TrainingDistributionCards({
- Polarization index{" "}
+ {TRAINING_TERMINOLOGY.polarization.valueLabel}{" "}
{latestPolarizationWeek.polarizationIndex === null
? "—"
: formatNumber(latestPolarizationWeek.polarizationIndex, 3)}
@@ -69,59 +83,69 @@ export function TrainingDistributionCards({
{formatIntensity(latestPolarizationWeek.zonePercentages.z2)} threshold ·{" "}
{formatIntensity(latestPolarizationWeek.zonePercentages.z3)} high
- {latestPolarizationWeek.explanation}
+
+ {TRAINING_TERMINOLOGY.polarization.plainDescription}
+
>
) : (
- No cycling polarization data in this period
+
+ No {TRAINING_TERMINOLOGY.polarization.plainLabel.toLowerCase()} data in this period
+
)}
- {polarization.method.formula}
- {polarization.method.zoneBasis}
- {polarization.method.calculationChoice}
- {polarization.method.interpretation}
- {
- void openExternalUrl(polarization.method.source.url, "polarization-source");
- }}
- >
- {polarization.method.source.title}
-
+
) : null}
{monotony ? (
- Training Monotony & Strain
+ {TRAINING_TERMINOLOGY.monotony.plainLabel}
{latestMonotonyWeek ? (
<>
- Monotony {formatNumber(latestMonotonyWeek.monotony, 2)}
+ {TRAINING_TERMINOLOGY.monotony.valueLabel}{" "}
+ {formatNumber(latestMonotonyWeek.monotony, 2)}
+
+
+ {TRAINING_TERMINOLOGY.monotony.strainLabel}{" "}
+ {formatNumber(latestMonotonyWeek.strain)}
- Strain {formatNumber(latestMonotonyWeek.strain)}
- Daily mean {formatNumber(latestMonotonyWeek.dailyMeanLoad, 2)} · population standard
- deviation (SD) {formatNumber(latestMonotonyWeek.dailyLoadStandardDeviation, 2)}
+ Average daily load {formatNumber(latestMonotonyWeek.dailyMeanLoad, 2)} · daily
+ variation {formatNumber(latestMonotonyWeek.dailyLoadStandardDeviation, 2)}
- {latestMonotonyWeek.method.formula}
- {latestMonotonyWeek.method.calendar}
- {latestMonotonyWeek.method.activityScope}
- {latestMonotonyWeek.method.interpretation}
- {
- void openExternalUrl(
- latestMonotonyWeek.method.source.url,
- "training-monotony-source",
- );
- }}
- >
- {latestMonotonyWeek.method.source.title}
-
+
>
) : (
- No training monotony data in this period
+
+ No {TRAINING_TERMINOLOGY.monotony.plainLabel.toLowerCase()} data in this period
+
)}
) : null}
@@ -200,12 +224,6 @@ const styles = StyleSheet.create({
fontSize: 12,
lineHeight: 18,
},
- sourceLink: {
- color: colors.accent,
- fontSize: 12,
- lineHeight: 18,
- textDecorationLine: "underline",
- },
emptyText: {
color: colors.textTertiary,
fontSize: 13,
diff --git a/packages/mobile/components/TrainingMethodDetails.tsx b/packages/mobile/components/TrainingMethodDetails.tsx
new file mode 100644
index 0000000000..631fef1a9c
--- /dev/null
+++ b/packages/mobile/components/TrainingMethodDetails.tsx
@@ -0,0 +1,57 @@
+import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
+import { openExternalUrl } from "../lib/open-external-url";
+import { colors } from "../theme";
+import { ChartDescriptionTooltip } from "./ChartDescriptionTooltip";
+
+interface TrainingMethodDetailsProps {
+ title: string;
+ technicalName: string;
+ lines: readonly string[];
+ source?: {
+ title: string;
+ url: string;
+ };
+ sourceActionId: string;
+}
+
+export function TrainingMethodDetails({
+ title,
+ technicalName,
+ lines,
+ source,
+ sourceActionId,
+}: TrainingMethodDetailsProps) {
+ const description = [`Technical name: ${technicalName}`, ...lines].join("\n\n");
+
+ return (
+
+
+ {source ? (
+ {
+ void openExternalUrl(source.url, sourceActionId);
+ }}
+ >
+ {source.title}
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: "flex-start",
+ gap: 8,
+ },
+ sourceLink: {
+ color: colors.accent,
+ fontSize: 12,
+ lineHeight: 18,
+ textDecorationLine: "underline",
+ },
+});
diff --git a/packages/training/src/terminology.ts b/packages/training/src/terminology.ts
new file mode 100644
index 0000000000..276e1dbd5f
--- /dev/null
+++ b/packages/training/src/terminology.ts
@@ -0,0 +1,74 @@
+export interface TrainingTerminologyEntry {
+ readonly plainLabel: string;
+ readonly plainDescription: string;
+ readonly technicalName: string;
+ readonly details: string;
+}
+
+/**
+ * User-facing names for training metrics. Plain labels belong in headings and
+ * chart series; technical names and calculation descriptions belong in details.
+ */
+export const TRAINING_TERMINOLOGY = {
+ intensityDistribution: {
+ plainLabel: "Heart-rate zone distribution",
+ plainDescription: "Shows how recorded heart-rate time is distributed across effort zones.",
+ technicalName: "Karvonen five-zone model",
+ details: "Heart-rate time is grouped into five intensity zones using heart-rate reserve.",
+ },
+ workloadRatio: {
+ plainLabel: "Recent-to-baseline workload ratio",
+ plainDescription: "Compares recent training load with the latest baseline period.",
+ technicalName: "Acute-to-chronic workload ratio (ACWR)",
+ details:
+ "Compares load from the latest 7 days with an equivalent 7-day baseline from the latest 28 days. This is descriptive context, not a safe range or an injury prediction.",
+ },
+ normalizedPower: {
+ plainLabel: "Effort-adjusted power",
+ plainDescription: "Power adjusted to reflect the extra effort of variable cycling.",
+ technicalName: "Normalized Power",
+ details:
+ "Uses 30-second rolling average power and fourth-power weighting to represent variable cycling effort.",
+ },
+ criticalPower: {
+ plainLabel: "Sustainable cycling power",
+ plainDescription: "Shows the cycling power estimated for longer efforts.",
+ technicalName: "Critical Power",
+ details:
+ "A power-duration model estimates the longer-effort power level and the finite work available above it.",
+ },
+ anaerobicWorkCapacity: {
+ plainLabel: "Short-burst power reserve",
+ plainDescription: "Shows the estimated power reserve available for short bursts.",
+ technicalName: "W′ (anaerobic work capacity)",
+ details:
+ "The model's finite work term represents the estimated power reserve above sustainable power.",
+ },
+ polarization: {
+ plainLabel: "Easy-to-hard training balance",
+ plainDescription: "Shows the balance of easy, threshold, and high-intensity cycling.",
+ valueLabel: "Easy-to-hard balance",
+ technicalName: "Polarization Index (Treff three-zone model)",
+ details: "Summarizes the recorded distribution of easy, threshold, and high-intensity cycling.",
+ },
+ monotony: {
+ plainLabel: "Training variety and total load",
+ plainDescription: "Shows how varied your weekly training load is.",
+ valueLabel: "Training variety",
+ strainLabel: "Weekly load strain",
+ technicalName: "Training Monotony (Foster method)",
+ details: "Describes how evenly cycling training load is distributed across each calendar week.",
+ },
+ gradeAdjustedPace: {
+ plainLabel: "Effort-adjusted pace for grade",
+ plainDescription: "Shows walking or hiking pace adjusted for slopes.",
+ technicalName: "Grade-adjusted pace (Minetti slope-cost model)",
+ details: "Adjusts walking or hiking pace for the estimated energy cost of the recorded slope.",
+ },
+ estimatedOneRepMax: {
+ plainLabel: "Estimated single-rep strength",
+ plainDescription: "Shows the estimated maximum weight for one repetition.",
+ technicalName: "Estimated 1-Rep Max (e1RM)",
+ details: "Uses the Epley formula: estimated max = weight × (1 + repetitions ÷ 30).",
+ },
+} as const satisfies Record>;
diff --git a/packages/web/src/components/ActivityVariabilityTable.test.tsx b/packages/web/src/components/ActivityVariabilityTable.test.tsx
index a412a99219..6a1c97b227 100644
--- a/packages/web/src/components/ActivityVariabilityTable.test.tsx
+++ b/packages/web/src/components/ActivityVariabilityTable.test.tsx
@@ -69,4 +69,19 @@ describe("ActivityVariabilityTable", () => {
screen.getByText("No cycling activities with enough power samples for variability yet."),
).toBeInTheDocument();
});
+
+ it("uses a plain-language power label in the activity table", () => {
+ render(
+ {}}
+ />,
+ );
+
+ expect(screen.getByRole("columnheader", { name: "Effort-adjusted power (W)" })).toBeTruthy();
+ expect(screen.queryByRole("columnheader", { name: "Normalized Power (W)" })).toBeNull();
+ });
});
diff --git a/packages/web/src/components/ActivityVariabilityTable.tsx b/packages/web/src/components/ActivityVariabilityTable.tsx
index 2db70b7eb4..69b339829d 100644
--- a/packages/web/src/components/ActivityVariabilityTable.tsx
+++ b/packages/web/src/components/ActivityVariabilityTable.tsx
@@ -1,4 +1,5 @@
import { formatDateShort, formatIntensity, formatNumber } from "@dofek/format/format";
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import type { ActivityVariabilityEmptyReason, ActivityVariabilityRow } from "dofek-server/types";
import { ActivityTable, type ActivityTableColumn } from "./ActivityTable.tsx";
import { PaginationControls } from "./PaginationControls.tsx";
@@ -72,7 +73,7 @@ export function ActivityVariabilityTable({
},
{
key: "normalizedPower",
- label: "Normalized Power (W)",
+ label: `${TRAINING_TERMINOLOGY.normalizedPower.plainLabel} (W)`,
headerClassName: "text-right py-2 px-3 text-muted font-medium",
cellClassName: "py-2 px-3 text-right text-foreground",
renderCell: (row) => formatNumber(row.normalizedPower),
diff --git a/packages/web/src/components/EstimatedMaxChart.test.tsx b/packages/web/src/components/EstimatedMaxChart.test.tsx
index 55e709c285..a1dd8ea9aa 100644
--- a/packages/web/src/components/EstimatedMaxChart.test.tsx
+++ b/packages/web/src/components/EstimatedMaxChart.test.tsx
@@ -114,4 +114,14 @@ describe("EstimatedMaxChart", () => {
showMaxLabel: true,
});
});
+
+ it("leads with plain-language strength and keeps e1RM method details expandable", () => {
+ render();
+
+ expect(screen.getByText("How this is calculated")).toBeVisible();
+ expect(screen.getByText(/Estimated 1-Rep Max \(e1RM\)/)).not.toBeVisible();
+ expect(screen.getByTestId("estimated-max-chart").dataset.option).toContain(
+ "Estimated single-rep strength",
+ );
+ });
});
diff --git a/packages/web/src/components/EstimatedMaxChart.tsx b/packages/web/src/components/EstimatedMaxChart.tsx
index 5a8c03c596..6aac7a8692 100644
--- a/packages/web/src/components/EstimatedMaxChart.tsx
+++ b/packages/web/src/components/EstimatedMaxChart.tsx
@@ -1,10 +1,12 @@
import { formatDateShort } from "@dofek/format/format";
import { formatMeasurementText } from "@dofek/format/units";
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import type { EstimatedOneRepMaxRow } from "dofek-server/types";
import { useState } from "react";
import { dofekAxis, dofekGrid, dofekSeries, dofekTooltip, seriesColor } from "../lib/chartTheme.ts";
import { useUnitConverter } from "../lib/unitContext.ts";
import { DofekChart } from "./DofekChart.tsx";
+import { MethodExplanation } from "./MethodExplanation.tsx";
interface EstimatedMaxChartProps {
exercises: EstimatedOneRepMaxRow[];
@@ -51,7 +53,9 @@ export function EstimatedMaxChart({ exercises, loading }: EstimatedMaxChartProps
showMaxLabel: true,
},
}),
- yAxis: dofekAxis.value({ name: `Estimated 1-Rep Max (${units.weightLabel})` }),
+ yAxis: dofekAxis.value({
+ name: `${TRAINING_TERMINOLOGY.estimatedOneRepMax.plainLabel} (${units.weightLabel})`,
+ }),
series,
};
@@ -102,6 +106,11 @@ export function EstimatedMaxChart({ exercises, loading }: EstimatedMaxChartProps
emptyMessage="No estimated max data"
height={280}
/>
+
);
}
diff --git a/packages/web/src/components/GradeAdjustedPaceTable.test.tsx b/packages/web/src/components/GradeAdjustedPaceTable.test.tsx
index 8bc563d76d..7d96c64dbb 100644
--- a/packages/web/src/components/GradeAdjustedPaceTable.test.tsx
+++ b/packages/web/src/components/GradeAdjustedPaceTable.test.tsx
@@ -129,5 +129,9 @@ describe("GradeAdjustedPaceTable", () => {
).toBeDefined();
expect(screen.getByRole("columnheader", { name: /Effort-adjusted pace/i })).toBeDefined();
expect(screen.getByText(/Effort-adjusted pace.*15%/)).toBeDefined();
+ for (const technicalDetail of screen.getAllByText(/Minetti/)) {
+ expect(technicalDetail).not.toBeVisible();
+ }
+ expect(screen.getByText("How this is calculated")).toBeDefined();
});
});
diff --git a/packages/web/src/components/GradeAdjustedPaceTable.tsx b/packages/web/src/components/GradeAdjustedPaceTable.tsx
index 152942e851..68591b701b 100644
--- a/packages/web/src/components/GradeAdjustedPaceTable.tsx
+++ b/packages/web/src/components/GradeAdjustedPaceTable.tsx
@@ -8,6 +8,7 @@ import type { GradeAdjustedPaceRow } from "dofek-server/types";
import { HIKING_PACE_COPY } from "../lib/hikingPaceCopy.ts";
import { useUnitConverter } from "../lib/unitContext.ts";
import { ActivityTable, type ActivityTableColumn } from "./ActivityTable.tsx";
+import { MethodExplanation } from "./MethodExplanation.tsx";
interface GradeAdjustedPaceTableProps {
data: GradeAdjustedPaceRow[];
@@ -106,6 +107,12 @@ export function GradeAdjustedPaceTable({ data, loading }: GradeAdjustedPaceTable
getActivityId={(row) => row.activityId}
/>
{HIKING_PACE_COPY.highlightNote}
+
);
}
diff --git a/packages/web/src/components/MethodExplanation.test.tsx b/packages/web/src/components/MethodExplanation.test.tsx
index 48bfdd3746..d6805c56dc 100644
--- a/packages/web/src/components/MethodExplanation.test.tsx
+++ b/packages/web/src/components/MethodExplanation.test.tsx
@@ -1,14 +1,15 @@
/** @vitest-environment jsdom */
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MethodExplanation } from "./MethodExplanation.tsx";
describe("MethodExplanation", () => {
- it("renders explanation lines in order with the primary source link", () => {
+ it("keeps technical method details collapsed until requested", () => {
const { container } = render(
{
/>,
);
- expect([...container.querySelectorAll("p")].map((line) => line.textContent)).toEqual([
+ expect(container.querySelector("details")).not.toBeNull();
+ expect(screen.getByText("How this is calculated")).toBeInTheDocument();
+ expect(screen.getByText("Formula details")).not.toBeVisible();
+
+ fireEvent.click(screen.getByText("How this is calculated"));
+ expect(
+ screen.getByText("Technical name: Polarization Index (Treff three-zone model)"),
+ ).toBeInTheDocument();
+ expect([...container.querySelectorAll("details p")].map((line) => line.textContent)).toEqual([
+ "Technical name: Polarization Index (Treff three-zone model)",
"Formula details",
"Calendar details",
"Interpretation details",
]);
- expect(container.firstElementChild).toHaveClass("mt-2", "space-y-1", "text-xs", "text-dim");
expect(screen.getByRole("link", { name: "Primary source" })).toHaveAttribute(
"href",
"https://example.com/primary-source",
);
- expect(screen.getByRole("link", { name: "Primary source" })).toHaveAttribute(
- "target",
- "_blank",
- );
- expect(screen.getByRole("link", { name: "Primary source" })).toHaveAttribute(
- "rel",
- "noreferrer",
- );
});
});
diff --git a/packages/web/src/components/MethodExplanation.tsx b/packages/web/src/components/MethodExplanation.tsx
index e2efbe23a4..dc5bc9289f 100644
--- a/packages/web/src/components/MethodExplanation.tsx
+++ b/packages/web/src/components/MethodExplanation.tsx
@@ -1,21 +1,40 @@
interface MethodExplanationProps {
className: string;
lines: readonly string[];
- source: {
+ technicalName?: string;
+ source?: {
title: string;
url: string;
};
}
-export function MethodExplanation({ className, lines, source }: MethodExplanationProps) {
+export function MethodExplanation({
+ className,
+ lines,
+ technicalName,
+ source,
+}: MethodExplanationProps) {
return (
-
+
+
+ How this is calculated
+
+
+ {technicalName ?
Technical name: {technicalName}
: null}
+ {lines.map((line) => (
+
{line}
+ ))}
+ {source ? (
+
+ {source.title}
+
+ ) : null}
+
+
);
}
diff --git a/packages/web/src/components/PolarizationTrendChart.test.tsx b/packages/web/src/components/PolarizationTrendChart.test.tsx
index 094bd26fa8..0d772adbd2 100644
--- a/packages/web/src/components/PolarizationTrendChart.test.tsx
+++ b/packages/web/src/components/PolarizationTrendChart.test.tsx
@@ -1,7 +1,7 @@
/** @vitest-environment jsdom */
import { DEFAULT_POLARIZATION_THRESHOLD } from "@dofek/training/training-distribution";
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("./DofekChart.tsx", () => ({
@@ -31,10 +31,12 @@ describe("PolarizationTrendChart", () => {
render();
- expect(screen.getByText(method.formula)).toBeTruthy();
- expect(screen.getByText(method.zoneBasis)).toBeTruthy();
- expect(screen.getByText(method.calculationChoice)).toBeTruthy();
- expect(screen.getByText(method.interpretation)).toBeTruthy();
+ expect(screen.getByText("How this is calculated")).toBeTruthy();
+ fireEvent.click(screen.getByText("How this is calculated"));
+ expect(screen.getByText(method.formula)).toBeVisible();
+ expect(screen.getByText(method.zoneBasis)).toBeVisible();
+ expect(screen.getByText(method.calculationChoice)).toBeVisible();
+ expect(screen.getByText(method.interpretation)).toBeVisible();
expect(screen.getByRole("link", { name: method.source.title })).toHaveAttribute(
"href",
method.source.url,
diff --git a/packages/web/src/components/PolarizationTrendChart.tsx b/packages/web/src/components/PolarizationTrendChart.tsx
index 092d8f9da9..be341a5b20 100644
--- a/packages/web/src/components/PolarizationTrendChart.tsx
+++ b/packages/web/src/components/PolarizationTrendChart.tsx
@@ -1,4 +1,5 @@
import { formatDateMedium, formatDateYmd, formatNumber } from "@dofek/format/format";
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import { DEFAULT_POLARIZATION_THRESHOLD } from "@dofek/training/training-distribution";
import type { PolarizationTrendResult, PolarizationWeek } from "dofek-server/types";
import {
@@ -77,7 +78,9 @@ export function buildPolarizationTrendOption(weeks: PolarizationWeek[], threshol
}>,
) => {
if (!params.length) return "";
- const piParam = params.find((param) => param.seriesName === "Polarization Index");
+ const piParam = params.find(
+ (param) => param.seriesName === TRAINING_TERMINOLOGY.polarization.valueLabel,
+ );
const param = piParam ?? params[0];
if (!param || typeof param.axisValue !== "string") return "";
@@ -94,7 +97,7 @@ export function buildPolarizationTrendOption(weeks: PolarizationWeek[], threshol
const status = `${escapeTooltipHtml(weekData.statusLabel)}`;
return [
`Week of ${escapeTooltipHtml(dateLabel)}`,
- `Polarization Index: ${piStr} ${status}`,
+ `${TRAINING_TERMINOLOGY.polarization.valueLabel}: ${piStr} ${status}`,
`Zone 1 (easy, <80% max HR): ${formatMinutes(weekData.z1Seconds)}`,
`Zone 2 (threshold, 80-90% max HR): ${formatMinutes(weekData.z2Seconds)}`,
`Zone 3 (high, ≥90% max HR): ${formatMinutes(weekData.z3Seconds)}`,
@@ -105,10 +108,14 @@ export function buildPolarizationTrendOption(weeks: PolarizationWeek[], threshol
},
}),
xAxis: dofekAxis.time(),
- yAxis: dofekAxis.value({ name: "Polarization Index", min: yMin, max: yMax }),
+ yAxis: dofekAxis.value({
+ name: TRAINING_TERMINOLOGY.polarization.valueLabel,
+ min: yMin,
+ max: yMax,
+ }),
series: [
{
- name: "Treff heuristic",
+ name: "Reference balance level",
type: "line",
data: [
[firstDate, effectiveThreshold],
@@ -121,7 +128,7 @@ export function buildPolarizationTrendOption(weeks: PolarizationWeek[], threshol
z: 1,
},
{
- name: "Polarization Index",
+ name: TRAINING_TERMINOLOGY.polarization.valueLabel,
type: "line",
data: weeks.map((w) => ({
value: [w.week, w.polarizationIndex],
@@ -169,7 +176,7 @@ export function PolarizationTrendChart({
return (
- Polarization Index (3-Zone Model)
+ {TRAINING_TERMINOLOGY.polarization.plainLabel}
{maxHr && (max heart rate: {maxHr} bpm)}
{method ? (
;
- };
-}
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
-describe("PowerCurveChart", () => {
- it("labels the fitted curve as the Critical Power model", () => {
- const element = PowerCurveChart({
- data: [{ durationSeconds: 300, label: "5min", bestPower: 350, activityDate: "2026-07-01" }],
- model: { cp: 300, wPrime: 20_000, r2: 0.95 },
- });
- if (!isValidElement(element)) {
- throw new Error("Expected PowerCurveChart to return a chart element");
- }
+vi.mock("./DofekChart.tsx", () => ({
+ DofekChart: ({ option }: { option: { series: Array<{ name: string }> } }) => (
+
+ ),
+}));
+
+import { PowerCurveChart } from "./PowerCurveChart.tsx";
- expect(element.props.option.series.map((series) => series.name)).toContain(
- "Critical Power model (300W, anaerobic work capacity=20kJ)",
+describe("PowerCurveChart", () => {
+ it("leads with a plain-language label for the fitted power model", () => {
+ render(
+ ,
);
+
+ expect(screen.getByTestId("power-curve").dataset.series).toContain("Sustainable power model");
+ expect(screen.getByText("How this is calculated")).toBeVisible();
+ expect(screen.getByText(/Critical Power/)).not.toBeVisible();
});
});
diff --git a/packages/web/src/components/PowerCurveChart.tsx b/packages/web/src/components/PowerCurveChart.tsx
index 916f3ac460..d11a8b782e 100644
--- a/packages/web/src/components/PowerCurveChart.tsx
+++ b/packages/web/src/components/PowerCurveChart.tsx
@@ -1,3 +1,4 @@
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import type { TrainingChartAvailability } from "dofek-server/types";
import {
chartColors,
@@ -10,6 +11,7 @@ import {
escapeTooltipHtml,
} from "../lib/chartTheme.ts";
import { DofekChart } from "./DofekChart.tsx";
+import { MethodExplanation } from "./MethodExplanation.tsx";
import { TrainingChartEmptyState } from "./TrainingChartEmptyState.tsx";
interface PowerCurvePoint {
@@ -80,14 +82,10 @@ export function PowerCurveChart({
if (modelCurveData.length > 0 && model) {
series.push(
- dofekSeries.line(
- `Critical Power model (${model.cp}W, anaerobic work capacity=${Math.round(model.wPrime / 1000)}kJ)`,
- modelCurveData,
- {
- color: chartColors.orange,
- lineStyle: { type: "dashed" },
- },
- ),
+ dofekSeries.line("Sustainable power model", modelCurveData, {
+ color: chartColors.orange,
+ lineStyle: { type: "dashed" },
+ }),
);
}
@@ -137,12 +135,25 @@ export function PowerCurveChart({
};
return (
-
+
+
+ {model ? (
+
+ ) : null}
+
);
}
diff --git a/packages/web/src/components/StrainCard.tsx b/packages/web/src/components/StrainCard.tsx
index 811fdb4699..c7a9cfd017 100644
--- a/packages/web/src/components/StrainCard.tsx
+++ b/packages/web/src/components/StrainCard.tsx
@@ -1,11 +1,13 @@
import { formatDateShort, formatIntensity, formatTrainingLoad } from "@dofek/format/format";
import { StrainScore } from "@dofek/scoring/scoring";
import { duration, easing } from "@dofek/scoring/tokens";
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import type { StrainTargetResult, WorkloadRatioResult } from "dofek-server/types";
import { useEffect, useState } from "react";
import { useCountUp } from "../hooks/useCountUp.ts";
import { chartThemeColors } from "../lib/chartTheme.ts";
import { ChartLoadingSkeleton } from "./LoadingSkeleton.tsx";
+import { MethodExplanation } from "./MethodExplanation.tsx";
interface StrainCardProps {
data: WorkloadRatioResult | undefined;
@@ -171,6 +173,11 @@ export function StrainCard({ data, strainTarget, loading }: StrainCardProps) {
{data.context.description}
+
{strainTarget && (
diff --git a/packages/web/src/components/TrainingInsightsPanel.test.tsx b/packages/web/src/components/TrainingInsightsPanel.test.tsx
index 333eff84d9..6e99cea6ce 100644
--- a/packages/web/src/components/TrainingInsightsPanel.test.tsx
+++ b/packages/web/src/components/TrainingInsightsPanel.test.tsx
@@ -23,7 +23,7 @@ describe("TrainingInsightsPanel range plumbing", () => {
expectRegistryInputs("trainingInsightsPanel", null);
});
- it("renders the server-owned descriptive intensity explanation", () => {
+ it("leads with a plain description and keeps the server explanation accessible", () => {
state.trainingHrZonesQuery = {
data: {
maxHr: 190,
@@ -56,10 +56,22 @@ describe("TrainingInsightsPanel range plumbing", () => {
render(
);
- expect(screen.getByText("Karvonen Intensity Distribution")).toBeDefined();
+ expect(screen.getByText("Heart-rate zone distribution")).toBeDefined();
expect(
- screen.getByText("Server says this is descriptive and is not a polarization classification."),
+ screen.getByText("Shows how recorded heart-rate time is distributed across effort zones."),
).toBeDefined();
+ expect(
+ screen.queryByText(
+ "Server says this is descriptive and is not a polarization classification.",
+ ),
+ ).toBeNull();
+
+ expect(screen.getByRole("button", { name: "About this chart" })).toHaveAttribute(
+ "data-description",
+ expect.stringContaining(
+ "Server says this is descriptive and is not a polarization classification.",
+ ),
+ );
});
it("renders query failures separately from an empty period", () => {
@@ -119,6 +131,8 @@ describe("TrainingInsightsPanel range plumbing", () => {
expect(screen.getByText("Weekly volume refresh failed")).toBeDefined();
expect(screen.getByText("Heart-rate zones refresh failed")).toBeDefined();
expect(screen.getByText("Weekly Training Volume")).toBeDefined();
- expect(screen.getByText("Cached intensity distribution.")).toBeDefined();
+ expect(
+ screen.getByText("Shows how recorded heart-rate time is distributed across effort zones."),
+ ).toBeDefined();
});
});
diff --git a/packages/web/src/components/TrainingInsightsPanel.tsx b/packages/web/src/components/TrainingInsightsPanel.tsx
index 43b77b7d19..159eb9427e 100644
--- a/packages/web/src/components/TrainingInsightsPanel.tsx
+++ b/packages/web/src/components/TrainingInsightsPanel.tsx
@@ -5,6 +5,7 @@ import {
formatIntensity,
} from "@dofek/format/format";
import { statusColors } from "@dofek/scoring/colors";
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import {
collapseWeeklyVolumeActivityTypes,
formatActivityTypeLabel,
@@ -261,11 +262,17 @@ function IntensityDonut({ distribution }: { distribution: IntensityDistribution
return (
-
Karvonen Intensity Distribution
-
+
+ {TRAINING_TERMINOLOGY.intensityDistribution.plainLabel}
+
+
-
{distribution.explanation}
+
+ {TRAINING_TERMINOLOGY.intensityDistribution.plainDescription}
+
);
}
diff --git a/packages/web/src/components/TrainingMonotonyChart.test.tsx b/packages/web/src/components/TrainingMonotonyChart.test.tsx
index 38dd2bec1b..a794888400 100644
--- a/packages/web/src/components/TrainingMonotonyChart.test.tsx
+++ b/packages/web/src/components/TrainingMonotonyChart.test.tsx
@@ -1,6 +1,6 @@
/** @vitest-environment jsdom */
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { chartColors } from "../lib/chartTheme.ts";
@@ -46,9 +46,11 @@ describe("TrainingMonotonyChart", () => {
it("renders the server-provided calculation choices and primary source", () => {
render(
);
- expect(screen.getByText(week.method.formula)).toBeTruthy();
- expect(screen.getByText(week.method.calendar)).toBeTruthy();
- expect(screen.getByText(week.method.interpretation)).toBeTruthy();
+ expect(screen.getByText("How this is calculated")).toBeTruthy();
+ fireEvent.click(screen.getByText("How this is calculated"));
+ expect(screen.getByText(week.method.formula)).toBeVisible();
+ expect(screen.getByText(week.method.calendar)).toBeVisible();
+ expect(screen.getByText(week.method.interpretation)).toBeVisible();
expect(screen.getByRole("link", { name: week.method.source.title })).toHaveAttribute(
"href",
week.method.source.url,
@@ -74,13 +76,13 @@ describe("TrainingMonotonyChart", () => {
}
const tooltip = option.tooltip.formatter([
{
- seriesName: "Monotony",
+ seriesName: "Training variety",
value: [week.week, week.monotony],
marker: "",
dataIndex: 0,
},
]);
- expect(tooltip).toContain("Daily mean cycling load: 161.14");
- expect(tooltip).toContain("Population standard deviation (SD): 73.92");
+ expect(tooltip).toContain("Average daily cycling load: 161.14");
+ expect(tooltip).toContain("Daily load variation: 73.92");
});
});
diff --git a/packages/web/src/components/TrainingMonotonyChart.tsx b/packages/web/src/components/TrainingMonotonyChart.tsx
index d248f68a31..063462b513 100644
--- a/packages/web/src/components/TrainingMonotonyChart.tsx
+++ b/packages/web/src/components/TrainingMonotonyChart.tsx
@@ -1,4 +1,5 @@
import { formatDateMedium, formatNumber } from "@dofek/format/format";
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import type { TrainingMonotonyWeek } from "dofek-server/types";
import {
chartColors,
@@ -39,23 +40,29 @@ export function TrainingMonotonyChart({ data, loading }: TrainingMonotonyChartPr
const dateLabel = formatDateMedium(dataPoint.week);
return [
`
${escapeTooltipHtml(dateLabel)}`,
- `Monotony:
${formatNumber(dataPoint.monotony, 2)}`,
- `Strain: ${formatNumber(dataPoint.strain)}`,
- `Daily mean cycling load: ${formatNumber(dataPoint.dailyMeanLoad, 2)}`,
- `Population standard deviation (SD): ${formatNumber(dataPoint.dailyLoadStandardDeviation, 2)}`,
+ `${TRAINING_TERMINOLOGY.monotony.valueLabel}:
${formatNumber(dataPoint.monotony, 2)}`,
+ `${TRAINING_TERMINOLOGY.monotony.strainLabel}: ${formatNumber(dataPoint.strain)}`,
+ `Average daily cycling load: ${formatNumber(dataPoint.dailyMeanLoad, 2)}`,
+ `Daily load variation: ${formatNumber(dataPoint.dailyLoadStandardDeviation, 2)}`,
].join("
");
},
}),
- legend: dofekLegend(true, { data: ["Monotony", "Strain"] }),
+ legend: dofekLegend(true, {
+ data: [TRAINING_TERMINOLOGY.monotony.valueLabel, TRAINING_TERMINOLOGY.monotony.strainLabel],
+ }),
xAxis: dofekAxis.time(),
yAxis: [
- dofekAxis.value({ name: "Monotony" }),
- dofekAxis.value({ name: "Strain", position: "right", showSplitLine: false }),
+ dofekAxis.value({ name: TRAINING_TERMINOLOGY.monotony.valueLabel }),
+ dofekAxis.value({
+ name: TRAINING_TERMINOLOGY.monotony.strainLabel,
+ position: "right",
+ showSplitLine: false,
+ }),
],
series: [
{
...dofekSeries.bar(
- "Monotony",
+ TRAINING_TERMINOLOGY.monotony.valueLabel,
data.map((d) => ({
value: [d.week, d.monotony],
itemStyle: {
@@ -66,7 +73,7 @@ export function TrainingMonotonyChart({ data, loading }: TrainingMonotonyChartPr
),
},
dofekSeries.line(
- "Strain",
+ TRAINING_TERMINOLOGY.monotony.strainLabel,
data.map((d) => [d.week, d.strain]),
{
color: chartColors.orange,
@@ -83,7 +90,14 @@ export function TrainingMonotonyChart({ data, loading }: TrainingMonotonyChartPr
{method ? (
) : null}
@@ -92,7 +106,7 @@ export function TrainingMonotonyChart({ data, loading }: TrainingMonotonyChartPr
loading={loading}
empty={data.length === 0}
height={300}
- emptyMessage="No training monotony data available"
+ emptyMessage="No weekly training variety data available"
/>
);
diff --git a/packages/web/src/components/WorkloadRatioChart.test.tsx b/packages/web/src/components/WorkloadRatioChart.test.tsx
index 4626ca775e..b155cb2272 100644
--- a/packages/web/src/components/WorkloadRatioChart.test.tsx
+++ b/packages/web/src/components/WorkloadRatioChart.test.tsx
@@ -1,6 +1,6 @@
/** @vitest-environment jsdom */
-import { render } from "@testing-library/react";
+import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
let chartOption: {
@@ -55,5 +55,7 @@ describe("WorkloadRatioChart", () => {
"20-day Baseline Load",
]);
expect(chartOption?.yAxis?.[0]?.name).toBe("Recent / baseline");
+ expect(screen.getByText("How this is calculated")).toBeVisible();
+ expect(screen.getByText(/Acute-to-chronic workload ratio/)).not.toBeVisible();
});
});
diff --git a/packages/web/src/components/WorkloadRatioChart.tsx b/packages/web/src/components/WorkloadRatioChart.tsx
index d2caf1f073..8173ac358f 100644
--- a/packages/web/src/components/WorkloadRatioChart.tsx
+++ b/packages/web/src/components/WorkloadRatioChart.tsx
@@ -1,4 +1,5 @@
import { formatDateShort, formatNumber, formatTrainingLoad } from "@dofek/format/format";
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import type { WorkloadRatioResult, WorkloadRatioRow } from "dofek-server/types";
import {
chartColors,
@@ -9,6 +10,7 @@ import {
escapeTooltipHtml,
} from "../lib/chartTheme.ts";
import { DofekChart } from "./DofekChart.tsx";
+import { MethodExplanation } from "./MethodExplanation.tsx";
interface WorkloadRatioChartProps {
data: WorkloadRatioRow[];
@@ -138,5 +140,14 @@ export function WorkloadRatioChart({ data, context, loading }: WorkloadRatioChar
],
};
- return ;
+ return (
+
+
+
+
+ );
}
diff --git a/packages/web/src/components/chart-options.test.ts b/packages/web/src/components/chart-options.test.ts
index 12d3eb6e48..ac387b9fa1 100644
--- a/packages/web/src/components/chart-options.test.ts
+++ b/packages/web/src/components/chart-options.test.ts
@@ -133,7 +133,7 @@ describe("PolarizationTrendChart option builder", () => {
const option = buildPolarizationTrendOption(weeksWithGap);
const series = getSeriesArray(option);
- const polarizationSeries = series.find((s) => s.name === "Polarization Index");
+ const polarizationSeries = series.find((s) => s.name === "Easy-to-hard balance");
expect(polarizationSeries).toBeDefined();
if (!polarizationSeries) throw new Error("Expected polarization series");
expect(polarizationSeries.data).toHaveLength(2);
@@ -150,7 +150,7 @@ describe("PolarizationTrendChart option builder", () => {
value: ["2024-01-01", 2.5],
dataIndex: 0,
color: "",
- seriesName: "Polarization Index",
+ seriesName: "Easy-to-hard balance",
},
]);
expect(html).toContain("<80% max HR");
@@ -177,7 +177,7 @@ describe("PolarizationTrendChart option builder", () => {
it("renders threshold as a regular line series at y=2.0", () => {
const option = buildPolarizationTrendOption(sampleWeeks);
const allSeries = getSeriesArray(option);
- const thresholdSeries = allSeries.find((s) => s.name === "Treff heuristic");
+ const thresholdSeries = allSeries.find((s) => s.name === "Reference balance level");
expect(thresholdSeries).toBeDefined();
if (!thresholdSeries) throw new Error("Expected threshold series");
expect(thresholdSeries.data[0]).toEqual(["2024-01-01", 2.0]);
@@ -187,7 +187,7 @@ describe("PolarizationTrendChart option builder", () => {
it("renders the threshold supplied by the server instead of a client heuristic", () => {
const option = buildPolarizationTrendOption(sampleWeeks, 2.5);
const allSeries = getSeriesArray(option);
- const thresholdSeries = allSeries.find((s) => s.name === "Treff heuristic");
+ const thresholdSeries = allSeries.find((s) => s.name === "Reference balance level");
expect(thresholdSeries).toBeDefined();
if (!thresholdSeries) throw new Error("Expected threshold series");
expect(thresholdSeries.data[0]).toEqual(["2024-01-01", 2.5]);
@@ -198,7 +198,7 @@ describe("PolarizationTrendChart option builder", () => {
for (const threshold of [undefined, null, Number.NaN]) {
const option = buildPolarizationTrendOption(sampleWeeks, threshold);
const allSeries = getSeriesArray(option);
- const thresholdSeries = allSeries.find((s) => s.name === "Treff heuristic");
+ const thresholdSeries = allSeries.find((s) => s.name === "Reference balance level");
expect(thresholdSeries).toBeDefined();
if (!thresholdSeries) throw new Error("Expected threshold series");
expect(thresholdSeries.data[0]).toEqual(["2024-01-01", 2]);
@@ -234,7 +234,7 @@ describe("PolarizationTrendChart option builder", () => {
];
const option = buildPolarizationTrendOption(weeksWithBoundary);
const allSeries = getSeriesArray(option);
- const polarizationIndexSeries = allSeries.find((s) => s.name === "Polarization Index");
+ const polarizationIndexSeries = allSeries.find((s) => s.name === "Easy-to-hard balance");
if (!polarizationIndexSeries) throw new Error("Expected polarization index series");
// The heuristic is descriptive, so neither side is encoded as good or bad.
expect(polarizationIndexSeries.data[0]).toHaveProperty("itemStyle", {
@@ -328,7 +328,7 @@ describe("PolarizationTrendChart option builder", () => {
value: ["2024-01-01", null],
dataIndex: 0,
color: "",
- seriesName: "Polarization Index",
+ seriesName: "Easy-to-hard balance",
},
]);
expect(html).toContain("Insufficient data");
diff --git a/packages/web/src/lib/hikingPaceCopy.ts b/packages/web/src/lib/hikingPaceCopy.ts
index f573e23e74..b9d8906be5 100644
--- a/packages/web/src/lib/hikingPaceCopy.ts
+++ b/packages/web/src/lib/hikingPaceCopy.ts
@@ -1,8 +1,15 @@
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
+
export const HIKING_PACE_COPY = {
- title: "Effort-adjusted pace for grade",
+ title: TRAINING_TERMINOLOGY.gradeAdjustedPace.plainLabel,
tableTitle: "Effort-adjusted pace by activity",
- description:
- "Pace adjusted for the effort of walking or hiking on slopes. Uses the Minetti slope-cost model.",
+ description: "Pace adjusted for the effort of walking or hiking on slopes.",
+ technicalName: TRAINING_TERMINOLOGY.gradeAdjustedPace.technicalName,
+ methodDetails: TRAINING_TERMINOLOGY.gradeAdjustedPace.details,
+ source: {
+ title: "Minetti et al. (2002), Energy cost of walking and running",
+ url: "https://pubmed.ncbi.nlm.nih.gov/12183501/",
+ },
columnLabel: "Effort-adjusted pace",
highlightNote:
"Effort-adjusted pace is highlighted in amber when it differs from actual pace by more than 15%.",
diff --git a/packages/web/src/routes/training/cycling.tsx b/packages/web/src/routes/training/cycling.tsx
index 85cc523a33..108d20a9e1 100644
--- a/packages/web/src/routes/training/cycling.tsx
+++ b/packages/web/src/routes/training/cycling.tsx
@@ -1,4 +1,5 @@
import { formatDateMedium, formatNumber } from "@dofek/format/format";
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { ActivityList } from "../../components/ActivityList.tsx";
@@ -179,7 +180,11 @@ function CyclingContent({ days }: { days: TimeRangeDays }) {
{/* eFTP Trend */}
{performance.error ? (
@@ -229,7 +234,7 @@ function CyclingContent({ days }: { days: TimeRangeDays }) {
{activityAnalytics.error ? (
@@ -356,13 +361,13 @@ function PowerSummaryTable({
unit=""
/>
@@ -415,7 +420,7 @@ function PeriodLabel({
{label}
{model && (
- Estimated Threshold Power {model.cp}W · Anaerobic work capacity (W′){" "}
+ Estimated sustainable power {model.cp}W · Short-burst power reserve{" "}
{Math.round(model.wPrime / 1000)}kJ
)}
@@ -451,11 +456,11 @@ function EstimateEvidencePanel({
{title}
{evidence.confidenceLabel}
- Method: {evidence.method}
- {evidence.confidenceDetail}
- Source workouts:
- {evidence.sourceWorkouts.length > 0 ? (
-
- {evidence.sourceWorkouts.map((workout) => (
- -
- {workout.name ?? "Cycling workout"} · {formatDateMedium(workout.date)}
-
- ))}
-
- ) : (
- No source workouts in this period.
- )}
- Pacing guidance: {evidence.pacingGuidance}
+
+
+ How this estimate is calculated
+
+
+
Method: {evidence.method}
+
{evidence.confidenceDetail}
+
Source workouts:
+ {evidence.sourceWorkouts.length > 0 ? (
+
+ {evidence.sourceWorkouts.map((workout) => (
+ -
+ {workout.name ?? "Cycling workout"} · {formatDateMedium(workout.date)}
+
+ ))}
+
+ ) : (
+
No source workouts in this period.
+ )}
+
Pacing guidance: {evidence.pacingGuidance}
+
+
);
}
diff --git a/packages/web/src/routes/training/endurance.tsx b/packages/web/src/routes/training/endurance.tsx
index 2999ec35b3..9a206bf78e 100644
--- a/packages/web/src/routes/training/endurance.tsx
+++ b/packages/web/src/routes/training/endurance.tsx
@@ -1,4 +1,5 @@
import { ENDURANCE_ACTIVITY_TYPES } from "@dofek/training/endurance-types";
+import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import { createFileRoute } from "@tanstack/react-router";
import { ChartDescriptionTooltip } from "../../components/ChartDescriptionTooltip.tsx";
import { PolarizationTrendChart } from "../../components/PolarizationTrendChart.tsx";
@@ -28,8 +29,8 @@ function EnduranceTab() {
return (
<>
{polarization.error ? (
@@ -60,7 +61,10 @@ function EnduranceTab() {
)}
-
+
{monotony.error ? (
) : (
diff --git a/packages/web/src/routes/training/hiking.test.tsx b/packages/web/src/routes/training/hiking.test.tsx
index c6a39385cb..9809a1f0c9 100644
--- a/packages/web/src/routes/training/hiking.test.tsx
+++ b/packages/web/src/routes/training/hiking.test.tsx
@@ -125,10 +125,13 @@ describe("HikingTab", () => {
expect(screen.getAllByText("Error in input stream")).toHaveLength(4);
});
- it("leads with plain meaning before disclosing the slope-cost model", async () => {
+ it("leads with plain meaning without exposing the slope-cost model in the subtitle", async () => {
await renderHikingTab();
expect(screen.getByRole("heading", { name: /Effort-adjusted pace for grade/i })).toBeDefined();
- expect(screen.getByText(/Pace adjusted.*Minetti slope-cost model/)).toBeDefined();
+ expect(
+ screen.getByText("Pace adjusted for the effort of walking or hiking on slopes."),
+ ).toBeDefined();
+ expect(screen.queryByText(/Minetti/)).toBeNull();
});
});
diff --git a/packages/web/src/routes/training/range-plumbing.test-helper.tsx b/packages/web/src/routes/training/range-plumbing.test-helper.tsx
index dcc8d753b1..446b75d87e 100644
--- a/packages/web/src/routes/training/range-plumbing.test-helper.tsx
+++ b/packages/web/src/routes/training/range-plumbing.test-helper.tsx
@@ -68,7 +68,11 @@ vi.mock("@tanstack/react-router", () => ({
}));
vi.mock("../../components/ChartDescriptionTooltip.tsx", () => ({
- ChartDescriptionTooltip: () => null,
+ ChartDescriptionTooltip: ({ description }: { description: string }) => (
+
+ ),
}));
vi.mock("../../components/EstimatedMaxChart.tsx", () => ({ EstimatedMaxChart: () => }));
vi.mock("../../components/HrvBaselineChart.tsx", () => ({ HrvBaselineChart: () => }));
diff --git a/packages/web/src/routes/training/strength.lazy.tsx b/packages/web/src/routes/training/strength.lazy.tsx
index 764f8bb7f4..c97b00a59e 100644
--- a/packages/web/src/routes/training/strength.lazy.tsx
+++ b/packages/web/src/routes/training/strength.lazy.tsx
@@ -67,8 +67,8 @@ function StrengthTab() {
{estimatedMax.error && !estimatedMax.data ? (
From dcda8518eebbac6295d5d13abc6c488ed7a5b3b4 Mon Sep 17 00:00:00 2001
From: Asher Cohen
Date: Sun, 2 Aug 2026 02:42:57 -0700
Subject: [PATCH 2/6] docs(incidents): record Infisical CI timeout
---
docs/production-incident-baseline.md | 37 ++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md
index c446239b5e..4f442d1082 100644
--- a/docs/production-incident-baseline.md
+++ b/docs/production-incident-baseline.md
@@ -7,6 +7,43 @@ full incident log or a replacement for runbooks. Use it to build shared memory
about the kinds of issues this system encounters, the signals that identified
them, and the durability work they suggest.
+## 2026-08-02: Mobile Metro CI validation blocked by Infisical network timeout
+
+### Symptoms
+
+The `Build Mobile / Metro Bundle` check for PR #2400 failed before the Metro
+bundle command ran.
+
+### User Impact
+
+There was no production or end-user impact. The pull request's mobile bundle
+validation was blocked, while local focused tests and typechecks remained
+available.
+
+### Evidence
+
+The first fatal line in [job 91481471693](https://github.com/Asherlc/dofek/actions/runs/30742155277/job/91481471693)
+was `dial tcp 44.207.179.12:443: i/o timeout` while the shared
+`load-infisical-secrets` action authenticated with Infisical's OIDC endpoint.
+The failure occurred before Metro bundling.
+
+### Root Cause
+
+The GitHub-hosted runner could not reach `app.infisical.com` during OIDC
+authentication, so required mobile build secrets were never loaded.
+
+### Fix or Mitigation
+
+No repository workaround, retry, timeout, or degraded-secret behavior was
+added. The code change was validated locally with focused web/mobile tests,
+typechecks, and static checks; CI should be rerun after Infisical connectivity
+recovers.
+
+### Remaining Risk
+
+The mobile Metro bundle and any dependent CI gates remain unverified until the
+Infisical OIDC request succeeds on a subsequent workflow run.
+
## 2026-08-02: iOS cold start blocked by Expo OTA launch wait
### Symptoms
From 437b59e16d1cb2fae12d4896c8f591c5853a8631 Mon Sep 17 00:00:00 2001
From: Asher Cohen
Date: Sun, 2 Aug 2026 02:57:52 -0700
Subject: [PATCH 3/6] test(training): cover terminology contract
---
packages/training/src/terminology.test.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
create mode 100644 packages/training/src/terminology.test.ts
diff --git a/packages/training/src/terminology.test.ts b/packages/training/src/terminology.test.ts
new file mode 100644
index 0000000000..18d6b6fc75
--- /dev/null
+++ b/packages/training/src/terminology.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vitest";
+import { TRAINING_TERMINOLOGY } from "./terminology.ts";
+
+describe("TRAINING_TERMINOLOGY", () => {
+ it("keeps the plain-language label separate from technical details", () => {
+ for (const entry of Object.values(TRAINING_TERMINOLOGY)) {
+ expect(entry.plainLabel).toBeTruthy();
+ expect(entry.plainDescription).toBeTruthy();
+ expect(entry.technicalName).toBeTruthy();
+ expect(entry.details).toBeTruthy();
+ expect(entry.plainLabel).not.toContain(entry.technicalName);
+ expect(entry.plainDescription).not.toContain(entry.technicalName);
+ }
+ });
+});
From a2f6a5979dcb269e8a7c7c5a1a40d7d540cda8ba Mon Sep 17 00:00:00 2001
From: Asher Cohen
Date: Sun, 2 Aug 2026 03:24:12 -0700
Subject: [PATCH 4/6] fix(training): address review feedback
---
.github/workflows/test.yml | 1 +
docs/production-incident-baseline.md | 12 +++++++++++-
packages/format/src/units.ts | 3 +++
packages/training/src/terminology.test.ts | 15 ---------------
.../src/components/ActivityVariabilityTable.tsx | 5 +++--
.../components/GradeAdjustedPaceTable.test.tsx | 2 +-
.../web/src/components/MethodExplanation.test.tsx | 3 ++-
.../web/src/components/PowerCurveChart.test.tsx | 14 ++++++++++++++
packages/web/src/components/PowerCurveChart.tsx | 7 ++++---
packages/web/src/components/StrainCard.test.tsx | 6 +++++-
packages/web/src/components/StrainCard.tsx | 1 -
packages/web/src/routes/training/hiking.test.tsx | 1 -
stryker.ci.config.json | 1 +
stryker.config.json | 1 +
14 files changed, 46 insertions(+), 26 deletions(-)
delete mode 100644 packages/training/src/terminology.test.ts
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1f53e9b626..79d8c4f1d5 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1084,6 +1084,7 @@ jobs:
| { grep -v 'src/db/drizzle-schema\.ts$' || true; } \
| { grep -v 'src/db/schema/' || true; } \
| { grep -v 'packages/server/src/types\.ts$' || true; } \
+ | { grep -v '^packages/training/src/terminology\.ts$' || true; } \
| { grep -v 'scripts/' || true; } \
| { grep -v '^cypress/' || true; } \
| { grep -v 'packages/mobile/' || true; } \
diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md
index 4f442d1082..a56108fdec 100644
--- a/docs/production-incident-baseline.md
+++ b/docs/production-incident-baseline.md
@@ -30,7 +30,8 @@ The failure occurred before Metro bundling.
### Root Cause
The GitHub-hosted runner could not reach `app.infisical.com` during OIDC
-authentication, so required mobile build secrets were never loaded.
+authentication, so required mobile build secrets were never loaded ([job
+91481471693](https://github.com/Asherlc/dofek/actions/runs/30742155277/job/91481471693)).
### Fix or Mitigation
@@ -44,6 +45,15 @@ recovers.
The mobile Metro bundle and any dependent CI gates remain unverified until the
Infisical OIDC request succeeds on a subsequent workflow run.
+### Follow-Up Work
+
+Rerun CI after Infisical connectivity recovers and retain the successful
+`Build Mobile / Metro Bundle` job and its dependent test-gate results as the
+validation evidence for this incident. The follow-up run is
+[CI run 30742835268](https://github.com/Asherlc/dofek/actions/runs/30742835268);
+no runtime retry or timeout change is warranted unless that run reproduces the
+connectivity failure.
+
## 2026-08-02: iOS cold start blocked by Expo OTA launch wait
### Symptoms
diff --git a/packages/format/src/units.ts b/packages/format/src/units.ts
index f604739a1d..f5c16abd74 100644
--- a/packages/format/src/units.ts
+++ b/packages/format/src/units.ts
@@ -2,6 +2,9 @@ import type { FormattedMeasurement, FormattedMeasurementPart, NullableNumber } f
export type UnitSystem = "metric" | "imperial";
+export const POWER_UNIT_LABEL = "W";
+export const WORK_UNIT_LABEL = "kJ";
+
// --- Conversion constants ---
const KG_TO_LBS = 2.20462;
const KM_TO_MILES = 0.621371;
diff --git a/packages/training/src/terminology.test.ts b/packages/training/src/terminology.test.ts
deleted file mode 100644
index 18d6b6fc75..0000000000
--- a/packages/training/src/terminology.test.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { TRAINING_TERMINOLOGY } from "./terminology.ts";
-
-describe("TRAINING_TERMINOLOGY", () => {
- it("keeps the plain-language label separate from technical details", () => {
- for (const entry of Object.values(TRAINING_TERMINOLOGY)) {
- expect(entry.plainLabel).toBeTruthy();
- expect(entry.plainDescription).toBeTruthy();
- expect(entry.technicalName).toBeTruthy();
- expect(entry.details).toBeTruthy();
- expect(entry.plainLabel).not.toContain(entry.technicalName);
- expect(entry.plainDescription).not.toContain(entry.technicalName);
- }
- });
-});
diff --git a/packages/web/src/components/ActivityVariabilityTable.tsx b/packages/web/src/components/ActivityVariabilityTable.tsx
index 69b339829d..57d70ad506 100644
--- a/packages/web/src/components/ActivityVariabilityTable.tsx
+++ b/packages/web/src/components/ActivityVariabilityTable.tsx
@@ -1,4 +1,5 @@
import { formatDateShort, formatIntensity, formatNumber } from "@dofek/format/format";
+import { POWER_UNIT_LABEL } from "@dofek/format/units";
import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import type { ActivityVariabilityEmptyReason, ActivityVariabilityRow } from "dofek-server/types";
import { ActivityTable, type ActivityTableColumn } from "./ActivityTable.tsx";
@@ -73,14 +74,14 @@ export function ActivityVariabilityTable({
},
{
key: "normalizedPower",
- label: `${TRAINING_TERMINOLOGY.normalizedPower.plainLabel} (W)`,
+ label: `${TRAINING_TERMINOLOGY.normalizedPower.plainLabel} (${POWER_UNIT_LABEL})`,
headerClassName: "text-right py-2 px-3 text-muted font-medium",
cellClassName: "py-2 px-3 text-right text-foreground",
renderCell: (row) => formatNumber(row.normalizedPower),
},
{
key: "averagePower",
- label: "Avg Power (W)",
+ label: `Avg Power (${POWER_UNIT_LABEL})`,
headerClassName: "text-right py-2 px-3 text-muted font-medium",
cellClassName: "py-2 px-3 text-right text-foreground",
renderCell: (row) => formatNumber(row.averagePower),
diff --git a/packages/web/src/components/GradeAdjustedPaceTable.test.tsx b/packages/web/src/components/GradeAdjustedPaceTable.test.tsx
index 7d96c64dbb..fc49bbcf16 100644
--- a/packages/web/src/components/GradeAdjustedPaceTable.test.tsx
+++ b/packages/web/src/components/GradeAdjustedPaceTable.test.tsx
@@ -132,6 +132,6 @@ describe("GradeAdjustedPaceTable", () => {
for (const technicalDetail of screen.getAllByText(/Minetti/)) {
expect(technicalDetail).not.toBeVisible();
}
- expect(screen.getByText("How this is calculated")).toBeDefined();
+ expect(screen.getByText("How this is calculated")).toBeVisible();
});
});
diff --git a/packages/web/src/components/MethodExplanation.test.tsx b/packages/web/src/components/MethodExplanation.test.tsx
index d6805c56dc..59851935c3 100644
--- a/packages/web/src/components/MethodExplanation.test.tsx
+++ b/packages/web/src/components/MethodExplanation.test.tsx
@@ -25,7 +25,8 @@ describe("MethodExplanation", () => {
fireEvent.click(screen.getByText("How this is calculated"));
expect(
screen.getByText("Technical name: Polarization Index (Treff three-zone model)"),
- ).toBeInTheDocument();
+ ).toBeVisible();
+ expect(screen.getByText("Formula details")).toBeVisible();
expect([...container.querySelectorAll("details p")].map((line) => line.textContent)).toEqual([
"Technical name: Polarization Index (Treff three-zone model)",
"Formula details",
diff --git a/packages/web/src/components/PowerCurveChart.test.tsx b/packages/web/src/components/PowerCurveChart.test.tsx
index 58d796b4de..c43fdad1eb 100644
--- a/packages/web/src/components/PowerCurveChart.test.tsx
+++ b/packages/web/src/components/PowerCurveChart.test.tsx
@@ -24,4 +24,18 @@ describe("PowerCurveChart", () => {
expect(screen.getByText("How this is calculated")).toBeVisible();
expect(screen.getByText(/Critical Power/)).not.toBeVisible();
});
+
+ it("hides the method disclosure when the fitted curve has no points", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("power-curve").dataset.series).not.toContain(
+ "Sustainable power model",
+ );
+ expect(screen.queryByText("How this is calculated")).toBeNull();
+ });
});
diff --git a/packages/web/src/components/PowerCurveChart.tsx b/packages/web/src/components/PowerCurveChart.tsx
index d11a8b782e..f22128bf5a 100644
--- a/packages/web/src/components/PowerCurveChart.tsx
+++ b/packages/web/src/components/PowerCurveChart.tsx
@@ -1,3 +1,4 @@
+import { POWER_UNIT_LABEL, WORK_UNIT_LABEL } from "@dofek/format/units";
import { TRAINING_TERMINOLOGY } from "@dofek/training/terminology";
import type { TrainingChartAvailability } from "dofek-server/types";
import {
@@ -110,7 +111,7 @@ export function PowerCurveChart({
trigger: "item",
formatter: (params: { data: [number, number]; seriesName: string }) => {
const [seconds, watts] = params.data;
- return `${escapeTooltipHtml(params.seriesName)}
${formatDuration(seconds)}: ${watts}W`;
+ return `${escapeTooltipHtml(params.seriesName)}
${formatDuration(seconds)}: ${watts}${POWER_UNIT_LABEL}`;
},
}),
xAxis: {
@@ -143,14 +144,14 @@ export function PowerCurveChart({
height={280}
emptyMessage="No power data"
/>
- {model ? (
+ {modelCurveData.length > 0 && model ? (
) : null}
diff --git a/packages/web/src/components/StrainCard.test.tsx b/packages/web/src/components/StrainCard.test.tsx
index d7189f784d..2085e4dc20 100644
--- a/packages/web/src/components/StrainCard.test.tsx
+++ b/packages/web/src/components/StrainCard.test.tsx
@@ -56,7 +56,11 @@ describe("StrainCard", () => {
screen.getByText(
"Compares load from the latest 7 days with an equivalent 7-day baseline from the latest 28 days. This is descriptive context, not a safe range or an injury prediction.",
),
- ).toBeTruthy();
+ ).not.toBeVisible();
+ expect(
+ screen.getByText("Technical name: Acute-to-chronic workload ratio (ACWR)"),
+ ).not.toBeVisible();
+ expect(screen.getByText("How this is calculated")).toBeVisible();
});
it("uses the standard count-up duration for the visible strain value", () => {
diff --git a/packages/web/src/components/StrainCard.tsx b/packages/web/src/components/StrainCard.tsx
index c7a9cfd017..2a400ff0a2 100644
--- a/packages/web/src/components/StrainCard.tsx
+++ b/packages/web/src/components/StrainCard.tsx
@@ -172,7 +172,6 @@ export function StrainCard({ data, strainTarget, loading }: StrainCardProps) {
{data.context.label}
- {data.context.description}
{
expect(
screen.getByText("Pace adjusted for the effort of walking or hiking on slopes."),
).toBeDefined();
- expect(screen.queryByText(/Minetti/)).toBeNull();
});
});
diff --git a/stryker.ci.config.json b/stryker.ci.config.json
index b68e7b81b7..e486088de3 100644
--- a/stryker.ci.config.json
+++ b/stryker.ci.config.json
@@ -58,6 +58,7 @@
"!packages/zepp/setting/**"
],
"ignorePatterns": [
+ "packages/training/src/terminology.ts",
"dist/**",
"packages/mobile/modules/whoop-ble/.build/**",
".adal/**",
diff --git a/stryker.config.json b/stryker.config.json
index aac3a94321..f3ae866477 100644
--- a/stryker.config.json
+++ b/stryker.config.json
@@ -55,6 +55,7 @@
"!packages/zepp/setting/**"
],
"ignorePatterns": [
+ "packages/training/src/terminology.ts",
"dist/**",
".adal/**",
".aider-desk/**",
From 6fde14fffc1730012e85a881f185c762150fc0bc Mon Sep 17 00:00:00 2001
From: Asher Cohen
Date: Sun, 2 Aug 2026 03:28:35 -0700
Subject: [PATCH 5/6] test(mobile): follow disclosure error copy
---
packages/mobile/app/(tabs)/strain.test.tsx | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/packages/mobile/app/(tabs)/strain.test.tsx b/packages/mobile/app/(tabs)/strain.test.tsx
index 673c47e680..6262464ffa 100644
--- a/packages/mobile/app/(tabs)/strain.test.tsx
+++ b/packages/mobile/app/(tabs)/strain.test.tsx
@@ -946,7 +946,13 @@ describe("StrainScreen recent activity navigation", () => {
render();
expect(screen.getAllByText("Training data failed to load")).toHaveLength(1);
- expect(screen.getByText("Independent intensity data remains available.")).toBeTruthy();
+ expect(
+ screen
+ .getByRole("button", {
+ name: "About How this is calculated for Heart-rate zone distribution",
+ })
+ .getAttribute("aria-description"),
+ ).toContain("Independent intensity data remains available.");
});
it("keeps equal messages separate when training and companion queries both fail", async () => {
From f0d39a8da073c1ab3e7ecb2b5e951cfb850c73c0 Mon Sep 17 00:00:00 2001
From: Asher Cohen
Date: Sun, 2 Aug 2026 03:36:22 -0700
Subject: [PATCH 6/6] style(web): format cycling route
---
packages/web/src/routes/training/cycling.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/web/src/routes/training/cycling.tsx b/packages/web/src/routes/training/cycling.tsx
index 1c842692a5..95b23916b7 100644
--- a/packages/web/src/routes/training/cycling.tsx
+++ b/packages/web/src/routes/training/cycling.tsx
@@ -225,9 +225,9 @@ function CyclingContent({ days }: { days: TimeRangeDays }) {
data={activityAnalytics.data?.verticalAscent ?? []}
availability={activityAnalytics.data?.availability?.verticalAscent}
loading={activityAnalytics.isLoading}
- />
-
-
+ />
+
+