diff --git a/messages/de.json b/messages/de.json index f21e32951..4677ce5c4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 471c64d75..491e58a87 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 19208af47..c248b9bca 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 49d7fda46..8e3250f06 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 2450e3800..f2a5173f1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index 4f6687a8b..a4d2e79ab 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -49,7 +49,9 @@ "dismiss": "닫기", "moreInfo": "추가 정보", "durationHoursMinutes": "{hours}시간 {minutes}분", - "durationMinutes": "{minutes}분" + "durationMinutes": "{minutes}분", + "durationClockMinutes": "{time}분", + "durationClockHours": "{time}시간" }, "apiErrors": { "measurement": { diff --git a/messages/pl.json b/messages/pl.json index f9a10cc81..b55cc6159 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx index e57a6e70f..aa52aac42 100644 --- a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -146,6 +146,6 @@ describe("", () => { expect(html).toContain("Running"); expect(html).toContain("Cycling"); expect(html).toContain("30m"); - expect(html).toContain("1h 00m"); + expect(html).toContain("1h 0m"); }); }); diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index cf77c7eeb..60c094a15 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -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)} - {formatDuration(workout.durationSec)} + {formatDurationMinutes( + Math.round(workout.durationSec / 60), + t, + )} diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx index 242331f7a..10adef82b 100644 --- a/src/components/insights/__tests__/workout-detail.test.tsx +++ b/src/components/insights/__tests__/workout-detail.test.tsx @@ -100,8 +100,8 @@ describe("", () => { it("renders the sport label, source, and duration summary", () => { const html = render(); 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("", () => { ); // 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("", () => { 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("5:46 min/km"); expect(html).toContain("12.5 m"); }); @@ -365,7 +365,7 @@ describe("", () => { 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"); }); }); diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index 9dfb6eadd..6edc47c4b 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -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 { + 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( diff --git a/src/components/insights/workout-detail/header.tsx b/src/components/insights/workout-detail/header.tsx index 4a54a9bea..1e4200f0b 100644 --- a/src/components/insights/workout-detail/header.tsx +++ b/src/components/insights/workout-detail/header.tsx @@ -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)}

- {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}

{sportName} @@ -65,7 +74,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {

- {formatDuration(workout.durationSec)} + {formatDuration(workout.durationSec, t)} {workout.distanceM != null ? ( diff --git a/src/components/insights/workout-detail/stats.tsx b/src/components/insights/workout-detail/stats.tsx index 8d2ab065a..36002d3bd 100644 --- a/src/components/insights/workout-detail/stats.tsx +++ b/src/components/insights/workout-detail/stats.tsx @@ -93,7 +93,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { tiles.push({ icon: , label: t("insights.workouts.detail.statsDuration"), - value: formatDuration(workout.durationSec), + value: formatDuration(workout.durationSec, t), }); if (workout.distanceM != null && workout.distanceM > 0) { diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index c39431619..4d0523d45 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -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({
- {formatDuration(workout.durationSec)} + {formatDurationMinutes( + Math.round(workout.durationSec / 60), + t, + )} {workout.distanceM != null ? (