mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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">
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user