Write workout durations and pace the reader's way

Workout durations read '38m 00s' and '1h 02m' in every locale, the pace
'5:51 /km', and the detail header printed its raw source key. The
detail now shows a clock reading with the locale's unit ('38:00 Min.',
'1:02:05 Std.'), the list and the dashboard tile the shared localised
minutes label, the pace 'min/km', and the header the source's name.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 19:07:37 +02:00
1 parent 8302f5f62a
commit a1b46eb2f4
14 files changed
+70 -42

No files matched your search

+3 -1
View File
@@ -49,7 +49,9 @@
"dismiss": "Schließen",
"moreInfo": "Mehr Informationen",
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
"durationMinutes": "{minutes} Min."
"durationMinutes": "{minutes} Min.",
"durationClockMinutes": "{time} Min.",
"durationClockHours": "{time} Std."
},
"apiErrors": {
"measurement": {
+3 -1
View File
@@ -49,7 +49,9 @@
"dismiss": "Dismiss",
"moreInfo": "More information",
"durationHoursMinutes": "{hours}h {minutes}m",
"durationMinutes": "{minutes}m"
"durationMinutes": "{minutes}m",
"durationClockMinutes": "{time} min",
"durationClockHours": "{time} h"
},
"apiErrors": {
"measurement": {
+3 -1
View File
@@ -49,7 +49,9 @@
"dismiss": "Descartar",
"moreInfo": "Más información",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationMinutes": "{minutes} min"
"durationMinutes": "{minutes} min",
"durationClockMinutes": "{time} min",
"durationClockHours": "{time} h"
},
"apiErrors": {
"measurement": {
+3 -1
View File
@@ -49,7 +49,9 @@
"dismiss": "Ignorer",
"moreInfo": "Plus d'informations",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationMinutes": "{minutes} min"
"durationMinutes": "{minutes} min",
"durationClockMinutes": "{time} min",
"durationClockHours": "{time} h"
},
"apiErrors": {
"measurement": {
+3 -1
View File
@@ -49,7 +49,9 @@
"dismiss": "Ignora",
"moreInfo": "Maggiori informazioni",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationMinutes": "{minutes} min"
"durationMinutes": "{minutes} min",
"durationClockMinutes": "{time} min",
"durationClockHours": "{time} h"
},
"apiErrors": {
"measurement": {
+3 -1
View File
@@ -49,7 +49,9 @@
"dismiss": "닫기",
"moreInfo": "추가 정보",
"durationHoursMinutes": "{hours}시간 {minutes}분",
"durationMinutes": "{minutes}분"
"durationMinutes": "{minutes}분",
"durationClockMinutes": "{time}분",
"durationClockHours": "{time}시간"
},
"apiErrors": {
"measurement": {
+3 -1
View File
@@ -49,7 +49,9 @@
"dismiss": "Odrzuć",
"moreInfo": "Więcej informacji",
"durationHoursMinutes": "{hours} godz. {minutes} min",
"durationMinutes": "{minutes} min"
"durationMinutes": "{minutes} min",
"durationClockMinutes": "{time} min",
"durationClockHours": "{time} godz."
},
"apiErrors": {
"measurement": {
@@ -146,6 +146,6 @@ describe("<RecentWorkoutsTile>", () => {
expect(html).toContain("Running");
expect(html).toContain("Cycling");
expect(html).toContain("30m");
expect(html).toContain("1h 00m");
expect(html).toContain("1h 0m");
});
});
@@ -3,6 +3,7 @@
import Link from "next/link";
import { Activity } from "lucide-react";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import { useTranslations } from "@/lib/i18n/context";
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
import { cn } from "@/lib/utils";
@@ -31,13 +32,6 @@ import { iconForSport } from "@/lib/workouts/sport-icons";
* round-trip per session.
*/
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m.toString().padStart(2, "0")}m`;
return `${m}m`;
}
function formatDayLabel(iso: string, locale: string): string {
const d = new Date(iso);
return getDateTimeFormat(locale, {
@@ -154,7 +148,10 @@ export function RecentWorkoutsTile() {
{formatDayLabel(workout.startedAt, locale)}
</span>
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
{formatDuration(workout.durationSec)}
{formatDurationMinutes(
Math.round(workout.durationSec / 60),
t,
)}
</span>
</Link>
</li>
@@ -100,8 +100,8 @@ describe("<WorkoutDetailHeader>", () => {
it("renders the sport label, source, and duration summary", () => {
const html = render(<WorkoutDetailHeader workout={FIXTURE} />);
expect(html).toContain("Running");
expect(html).toContain("APPLE_HEALTH");
expect(html).toContain("30m 00s");
expect(html).toContain("Apple Health");
expect(html).toContain("30:00 min");
expect(html).toContain("5.20 km");
});
});
@@ -180,7 +180,7 @@ describe("<WorkoutDetailStats>", () => {
);
// 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("9:17 min/mi");
expect(html).toContain("41.0 ft");
// The sport-average line: 5800 m = 3.60 mi.
expect(html).toContain("3.60 mi");
@@ -196,7 +196,7 @@ describe("<WorkoutDetailStats>", () => {
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("5:46 min/km");
expect(html).toContain("12.5 m");
});
@@ -365,7 +365,7 @@ describe("<WorkoutDetailSplits>", () => {
expectSingleSectionHeading(html, "Splits");
expect(html).toContain('data-slot="workout-detail-splits"');
expect(html).toContain("5:00");
expect(html).toContain("4:48 /km");
expect(html).toContain("4:48 min/km");
});
});
@@ -17,14 +17,25 @@ import {
* durations and distances identically.
*/
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`;
}
return `${m}m ${s.toString().padStart(2, "0")}s`;
/**
* A workout's length as a clock reading with its unit, the way a sports
* watch shows it: "38:00 Min." / "1:02:05 Std." in German, "38:00 min" /
* "1:02:05 h" in English. The old "38m 00s" was English in every locale.
*/
export function formatDuration(
seconds: number,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const total = Math.max(0, Math.round(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const ss = s.toString().padStart(2, "0");
return h > 0
? t("common.durationClockHours", {
time: `${h}:${m.toString().padStart(2, "0")}:${ss}`,
})
: t("common.durationClockMinutes", { time: `${m}:${ss}` });
}
/** Compact "34 min" style duration for the sport-average comparison line. */
@@ -95,7 +106,7 @@ export function formatPace(
}
/**
* "m:ss /km" from a seconds-per-unit value. The splits table passes "km"
* "m:ss min/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.
*/
@@ -107,7 +118,7 @@ export function formatPaceSeconds(secPerUnit: number, unit: string): string {
m += 1;
s = 0;
}
return `${m}:${s.toString().padStart(2, "0")} /${unit}`;
return `${m}:${s.toString().padStart(2, "0")} min/${unit}`;
}
export function formatDateRange(
@@ -1,6 +1,7 @@
"use client";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
@@ -48,7 +49,15 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
{renderSportIconBadge(workout.sportType)}
<div className="min-w-0 flex-1 space-y-1">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{workout.source}
{MEASUREMENT_SOURCE_LIST_LABEL_KEYS[
workout.source as keyof typeof MEASUREMENT_SOURCE_LIST_LABEL_KEYS
]
? t(
MEASUREMENT_SOURCE_LIST_LABEL_KEYS[
workout.source as keyof typeof MEASUREMENT_SOURCE_LIST_LABEL_KEYS
],
)
: workout.source}
</p>
<h2 className="truncate text-lg font-semibold sm:text-xl">
{sportName}
@@ -65,7 +74,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
</div>
<div className="flex items-center gap-2 sm:flex-col sm:items-end sm:gap-0 sm:text-right">
<span className="text-2xl font-semibold tabular-nums">
{formatDuration(workout.durationSec)}
{formatDuration(workout.durationSec, t)}
</span>
{workout.distanceM != null ? (
<span className="text-muted-foreground text-xs tabular-nums">
@@ -93,7 +93,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
tiles.push({
icon: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsDuration"),
value: formatDuration(workout.durationSec),
value: formatDuration(workout.durationSec, t),
});
if (workout.distanceM != null && workout.distanceM > 0) {
+5 -8
View File
@@ -6,6 +6,7 @@ import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import {
hourCycleOptions,
@@ -29,13 +30,6 @@ import { formatDistance } from "@/components/insights/workout-detail/format";
* lands here.
*/
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m.toString().padStart(2, "0")}m`;
return `${m}m`;
}
function formatEnergy(kcal: number, locale: string): string {
return getNumberFormat(locale, {
maximumFractionDigits: 0,
@@ -151,7 +145,10 @@ export function WorkoutList({
</div>
<div className="flex flex-col items-end gap-0.5 text-xs">
<span className="font-medium tabular-nums">
{formatDuration(workout.durationSec)}
{formatDurationMinutes(
Math.round(workout.durationSec / 60),
t,
)}
</span>
<span className="text-muted-foreground flex flex-wrap justify-end gap-x-2 tabular-nums">
{workout.distanceM != null ? (