mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
8302f5f62a
commit
a1b46eb2f4
14 files changed
+70
-42
No files matched your search
+3
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -49,7 +49,9 @@
|
||||
"dismiss": "닫기",
|
||||
"moreInfo": "추가 정보",
|
||||
"durationHoursMinutes": "{hours}시간 {minutes}분",
|
||||
"durationMinutes": "{minutes}분"
|
||||
"durationMinutes": "{minutes}분",
|
||||
"durationClockMinutes": "{time}분",
|
||||
"durationClockHours": "{time}시간"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+3
-1
@@ -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) {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user