fix(workouts): show distance, pace and elevation in the reader's units

The workout list, the detail header, the stat tiles, the sport-average
line and the elevation chart printed kilometres, minutes per kilometre
and metres for every reader. They now read distance, pace and elevation
through the unit registry (km or mi, /km or /mi, m or ft). The splits
table keeps its kilometre rows, which are cut server-side, and says so.
A pace that rounded to sixty seconds now carries into the minute.

Refs #1067
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:03:51 +02:00
1 parent 5486014360
commit d6f79c8bef
10 files changed
+184 -43

No files matched your search

@@ -11,6 +11,11 @@ import {
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
applyDisplayTransformUnrounded,
getQuantityTransform,
} from "@/lib/measurements/display-transform";
/**
* Workout elevation profile — a small cumulative-distance / altitude
@@ -30,7 +35,15 @@ export function WorkoutElevationChart({
points: WorkoutElevationPoint[];
}) {
const { t } = useTranslations();
const data = points.map((p) => ({ km: p.distanceM / 1000, alt: p.altitude }));
const { preference } = useUnitDisplay();
// Distance along the route in km or mi, altitude in m or ft — the same
// registry the stat tiles read, so the axis and the tiles agree.
const distance = getQuantityTransform("distance", preference);
const elevation = getQuantityTransform("elevation", preference);
const data = points.map((p) => ({
dist: applyDisplayTransformUnrounded(p.distanceM, distance),
alt: applyDisplayTransformUnrounded(p.altitude, elevation),
}));
return (
<div className="h-24 w-full" data-slot="workout-elevation-chart">
@@ -57,7 +70,7 @@ export function WorkoutElevationChart({
opacity={0.4}
/>
<XAxis
dataKey="km"
dataKey="dist"
type="number"
domain={[0, "dataMax"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
@@ -80,9 +93,11 @@ export function WorkoutElevationChart({
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
labelFormatter={(km) => `${Number(km).toFixed(2)} km`}
labelFormatter={(d) =>
`${Number(d).toFixed(2)} ${distance.displayUnit}`
}
formatter={(value) => [
`${Math.round(Number(value))} m`,
`${Math.round(Number(value))} ${elevation.displayUnit}`,
t("insights.workouts.detail.elevationTitle"),
]}
/>
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -16,6 +16,19 @@ import { WorkoutInsightCard } from "../workout-detail/insight-slot";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
// The unit preference comes from the account payload; a mutable stub lets a
// test switch the reader to imperial.
const account = vi.hoisted(() => ({
user: { unitPreference: "metric", glucoseUnit: null } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
/**
* #67 — `<WorkoutDetail*>` unit tests over the split `workout-detail/`
* directory. Each primitive runs through SSR with a canonical fixture;
@@ -149,6 +162,44 @@ describe("<WorkoutDetailStats>", () => {
expect(html).toContain("148 bpm");
});
it("states distance, pace, elevation and the average line in imperial units", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
try {
const html = render(
<WorkoutDetailStats
workout={{
...FIXTURE,
sportContext: {
count: 8,
avgDurationSec: 2040,
avgDistanceM: 5800,
avgAvgHr: 148,
},
}}
/>,
);
// 5200 m = 3.23 mi; 1800 s over it = 9:17 per mile; 12.5 m = 41.0 ft.
expect(html).toContain("3.23 mi");
expect(html).toContain("9:17 /mi");
expect(html).toContain("41.0 ft");
// The sport-average line: 5800 m = 3.60 mi.
expect(html).toContain("3.60 mi");
expect(html).not.toContain(" km");
expect(html).not.toContain("/km");
const header = render(<WorkoutDetailHeader workout={FIXTURE} />);
expect(header).toContain("3.23 mi");
} finally {
account.user = { unitPreference: "metric", glucoseUnit: null };
}
});
it("keeps kilometres, minutes per km and metres for a metric reader", () => {
const html = render(<WorkoutDetailStats workout={FIXTURE} />);
expect(html).toContain("5.20 km");
expect(html).toContain("5:46 /km");
expect(html).toContain("12.5 m");
});
it("hides the average line for a lone session", () => {
const lone: WorkoutDetailPayload = {
...FIXTURE,
@@ -1,10 +1,23 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { WorkoutList } from "../workout-list";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
// The unit preference comes from the account payload; a mutable stub lets a
// test switch the reader to imperial.
const account = vi.hoisted(() => ({
user: { unitPreference: "metric", glucoseUnit: null } as Record<
string,
unknown
>,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: account.user, isAuthenticated: true }),
useAccountOnceMounted: () => account.user,
}));
/**
* v1.4.32 — `<WorkoutList>` unit tests.
*
@@ -47,6 +60,18 @@ describe("<WorkoutList>", () => {
expect(html).toContain("320 kcal");
});
it("states the distance in miles for an imperial reader", () => {
account.user = { unitPreference: "imperial", glucoseUnit: null };
try {
const html = render(<WorkoutList workouts={[ROW]} />);
// 5200 m = 3.23 mi.
expect(html).toContain("3.23 mi");
expect(html).not.toContain(" km");
} finally {
account.user = { unitPreference: "metric", glucoseUnit: null };
}
});
it("renders aggregate average HR without claiming an HR series", () => {
const html = render(
<WorkoutList workouts={[{ ...ROW, hasHrSeries: false }]} />,
@@ -1,4 +1,11 @@
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import {
applyDisplayTransform,
applyDisplayTransformUnrounded,
getQuantityTransform,
paceSecondsPerDistanceUnit,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import {
hourCycleOptions,
type TimeFormatPreference,
@@ -27,12 +34,38 @@ export function formatDurationMinutes(seconds: number, locale: string): string {
return formatNumber(minutes, locale);
}
export function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return getNumberFormat(locale, {
/**
* A workout distance in the reader's unit, with its symbol: "5.80 km" or
* "3.60 mi". The workout list, the detail header, the stat tile and the
* sport-average line all print through this, so none of them can keep
* kilometres for an imperial reader.
*/
export function formatDistance(
meters: number,
locale: string,
preference: UnitPreference,
): string {
const transform = getQuantityTransform("distance", preference);
const value = applyDisplayTransformUnrounded(meters, transform);
const formatted = getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
minimumFractionDigits: value < 10 ? 2 : 1,
}).format(value);
return `${formatted} ${transform.displayUnit}`;
}
/** Elevation gain in the reader's unit with its symbol: "123.4 m" / "404.9 ft". */
export function formatElevation(
meters: number,
locale: string,
preference: UnitPreference,
): string {
const transform = getQuantityTransform("elevation", preference);
return `${formatNumber(
applyDisplayTransform(meters, transform),
locale,
transform.decimals,
)} ${transform.displayUnit}`;
}
export function formatNumber(
@@ -46,21 +79,36 @@ export function formatNumber(
}).format(value);
}
export function formatPaceMinPerKm(
/**
* Average pace per kilometre or per mile, by the reader's preference. Only
* meaningful for run / walk / hike / ride; the caller gates on distance +
* sport.
*/
export function formatPace(
durationSec: number,
meters: number,
preference: UnitPreference,
): string {
// Seconds-per-kilometre. Only meaningful for run / walk / hike / ride;
// the caller gates on distance + sport.
const secPerKm = (durationSec / meters) * 1000;
return formatPaceSeconds(secPerKm);
return formatPaceSeconds(
paceSecondsPerDistanceUnit(durationSec, meters, preference),
getQuantityTransform("distance", preference).displayUnit,
);
}
/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */
export function formatPaceSeconds(secPerKm: number): string {
const m = Math.floor(secPerKm / 60);
const s = Math.round(secPerKm % 60);
return `${m}:${s.toString().padStart(2, "0")} /km`;
/**
* "m:ss /km" from a seconds-per-unit value. The splits table passes "km"
* explicitly: its rows are kilometre segments cut server-side, so their
* pace is per kilometre whatever the preference.
*/
export function formatPaceSeconds(secPerUnit: number, unit: string): string {
let m = Math.floor(secPerUnit / 60);
let s = Math.round(secPerUnit % 60);
// 359.6 s is 6:00, not 5:60.
if (s === 60) {
m += 1;
s = 0;
}
return `${m}:${s.toString().padStart(2, "0")} /${unit}`;
}
export function formatDateRange(
@@ -3,8 +3,9 @@
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDuration, formatDistanceKm, formatDateRange } from "./format";
import { formatDuration, formatDistance, formatDateRange } from "./format";
/**
* Hero header — the at-a-glance verdict row. Sport icon badge, source
@@ -30,6 +31,7 @@ function renderSportIconBadge(sportType: string) {
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const { preference } = useUnitDisplay();
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
@@ -67,7 +69,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
</span>
{workout.distanceM != null ? (
<span className="text-muted-foreground text-xs tabular-nums">
{formatDistanceKm(workout.distanceM, locale)} km
{formatDistance(workout.distanceM, locale, preference)}
</span>
) : null}
</div>
@@ -70,7 +70,7 @@ export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) {
: "py-1.5 text-right tabular-nums"
}
>
{formatPaceSeconds(s.paceSecPerKm)}
{formatPaceSeconds(s.paceSecPerKm, "km")}
</td>
</tr>
))}
@@ -17,12 +17,15 @@ import { TileHeader } from "@/components/insights/tile-header";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import type { UnitPreference } from "@/lib/measurements/display-transform";
import {
formatDuration,
formatDistanceKm,
formatDistance,
formatElevation,
formatNumber,
formatPaceMinPerKm,
formatPace,
formatDurationMinutes,
} from "./format";
@@ -69,12 +72,13 @@ const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
function sportAverageLine(
workout: WorkoutDetailPayload,
locale: string,
preference: UnitPreference,
): string | null {
const ctx = workout.sportContext;
if (!ctx || ctx.count < 2) return null;
const parts: string[] = [];
if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) {
parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`);
parts.push(formatDistance(ctx.avgDistanceM, locale, preference));
}
parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`);
if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`);
@@ -83,6 +87,7 @@ function sportAverageLine(
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
const { t, locale } = useTranslations();
const { preference } = useUnitDisplay();
const tiles: StatTileProps[] = [];
tiles.push({
@@ -95,7 +100,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Map className="size-4" />,
label: t("insights.workouts.detail.statsDistance"),
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
value: formatDistance(workout.distanceM, locale, preference),
});
}
@@ -139,7 +144,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Mountain className="size-4" />,
label: t("insights.workouts.detail.statsElevation"),
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
value: formatElevation(workout.elevationM, locale, preference),
});
}
@@ -151,11 +156,11 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsPace"),
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM),
value: formatPace(workout.durationSec, workout.distanceM, preference),
});
}
const averageLine = sportAverageLine(workout, locale);
const averageLine = sportAverageLine(workout, locale, preference);
return (
<Card data-slot="workout-detail-stats">
+4 -9
View File
@@ -13,6 +13,8 @@ import {
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
import { Button } from "@/components/ui/button";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDistance } from "@/components/insights/workout-detail/format";
/**
* v1.4.32 — workout list row primitive.
@@ -33,14 +35,6 @@ function formatDuration(seconds: number): string {
return `${m}m`;
}
function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
}
function formatEnergy(kcal: number, locale: string): string {
return getNumberFormat(locale, {
maximumFractionDigits: 0,
@@ -86,6 +80,7 @@ export function WorkoutList({
}: WorkoutListProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const { preference } = useUnitDisplay();
return (
<ul
@@ -150,7 +145,7 @@ export function WorkoutList({
<span className="text-muted-foreground flex flex-wrap justify-end gap-x-2 tabular-nums">
{workout.distanceM != null ? (
<span data-slot="workout-list-distance">
{formatDistanceKm(workout.distanceM, locale)} km
{formatDistance(workout.distanceM, locale, preference)}
</span>
) : null}
{workout.activeEnergyKcal != null ? (
@@ -384,7 +384,7 @@ describe("display-transform — both unit preferences, one resolver", () => {
expect(mi.displayUnit).toBe("mi");
expect(
applyDisplayTransform(100, getQuantityTransform("elevation", "imperial")),
).toBe(328);
).toBe(328.1);
expect(getQuantityTransform("elevation", "metric").displayUnit).toBe("m");
// 30 min over 5 km: 360 s/km, 579.4 s/mi.
expect(paceSecondsPerDistanceUnit(1800, 5000, "metric")).toBeCloseTo(
+2 -2
View File
@@ -260,8 +260,8 @@ const QUANTITY_TRANSFORMS: Record<
imperial: { factor: 0.000621371192237, displayUnit: "mi", decimals: 2 },
},
elevation: {
metric: { factor: 1, displayUnit: "m", decimals: 0 },
imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 0 },
metric: { factor: 1, displayUnit: "m", decimals: 1 },
imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 1 },
},
};