diff --git a/src/components/charts/workout-elevation-chart.tsx b/src/components/charts/workout-elevation-chart.tsx index 8c565bd11..3fd0cfa93 100644 --- a/src/components/charts/workout-elevation-chart.tsx +++ b/src/components/charts/workout-elevation-chart.tsx @@ -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 (
@@ -57,7 +70,7 @@ export function WorkoutElevationChart({ opacity={0.4} /> `${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"), ]} /> diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx index a7916eaaa..242331f7a 100644 --- a/src/components/insights/__tests__/workout-detail.test.tsx +++ b/src/components/insights/__tests__/workout-detail.test.tsx @@ -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 — `` unit tests over the split `workout-detail/` * directory. Each primitive runs through SSR with a canonical fixture; @@ -149,6 +162,44 @@ describe("", () => { 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( + , + ); + // 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(); + 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(); + 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, diff --git a/src/components/insights/__tests__/workout-list.test.tsx b/src/components/insights/__tests__/workout-list.test.tsx index d6cb72ec5..a3dad821a 100644 --- a/src/components/insights/__tests__/workout-list.test.tsx +++ b/src/components/insights/__tests__/workout-list.test.tsx @@ -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 — `` unit tests. * @@ -47,6 +60,18 @@ describe("", () => { 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(); + // 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( , diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index 50fc6f775..03e559ea1 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -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( diff --git a/src/components/insights/workout-detail/header.tsx b/src/components/insights/workout-detail/header.tsx index 172316af1..4a54a9bea 100644 --- a/src/components/insights/workout-detail/header.tsx +++ b/src/components/insights/workout-detail/header.tsx @@ -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) { {workout.distanceM != null ? ( - {formatDistanceKm(workout.distanceM, locale)} km + {formatDistance(workout.distanceM, locale, preference)} ) : null}
diff --git a/src/components/insights/workout-detail/splits.tsx b/src/components/insights/workout-detail/splits.tsx index 42fafe68e..16d228a8e 100644 --- a/src/components/insights/workout-detail/splits.tsx +++ b/src/components/insights/workout-detail/splits.tsx @@ -70,7 +70,7 @@ export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) { : "py-1.5 text-right tabular-nums" } > - {formatPaceSeconds(s.paceSecPerKm)} + {formatPaceSeconds(s.paceSecPerKm, "km")} ))} diff --git a/src/components/insights/workout-detail/stats.tsx b/src/components/insights/workout-detail/stats.tsx index 72767d043..8d2ab065a 100644 --- a/src/components/insights/workout-detail/stats.tsx +++ b/src/components/insights/workout-detail/stats.tsx @@ -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: , 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: , 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: , 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 ( diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 150dcb106..ab9130316 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -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 (
    {workout.distanceM != null ? ( - {formatDistanceKm(workout.distanceM, locale)} km + {formatDistance(workout.distanceM, locale, preference)} ) : null} {workout.activeEnergyKcal != null ? ( diff --git a/src/lib/measurements/__tests__/display-transform.test.ts b/src/lib/measurements/__tests__/display-transform.test.ts index 91f23e674..62fa98d33 100644 --- a/src/lib/measurements/__tests__/display-transform.test.ts +++ b/src/lib/measurements/__tests__/display-transform.test.ts @@ -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( diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 5e1cba548..72b7426d1 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -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 }, }, };