From 0d45d0ab53956d457dda5a831e97f1a0ea43b3e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 29 Jun 2026 15:47:48 +0200 Subject: [PATCH] fix(format): honour the time-format preference on every clock renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The H24 / H12 preference only reached makeFormatters().time. Surfaces that build their own Intl.DateTimeFormat — the sleep hypnogram axis, the workout list and detail headers — omitted the hour cycle, so an en-locale (or AUTO) user saw AM/PM even with H24 selected. The security activity feed and session list rendered raw toLocaleString(), bypassing both the locale and the preference entirely. Export hourCycleOptions from format-locale as the single source for the H12 / H24 / AUTO Intl options and spread it into the self-built formatters, preserving each surface's locale and timezone. Route the security cards through useFormatters().dateTime. Drop the date-time field's local copy of the helper. Guards: the helper contract, the en-US hour/minute call-site pattern (no AM/PM under H24, AM/PM under H12), and formatUpdatedLabel fed an H24 formatter for the briefing freshness label. --- src/components/insights/sleep-hypnogram.tsx | 7 ++- src/components/insights/workout-detail.tsx | 16 ++++++- src/components/insights/workout-list.tsx | 16 +++++-- .../settings/security-activity-card.tsx | 5 +- .../settings/security-sessions-card.tsx | 5 +- src/components/ui/date-time-field.tsx | 15 +----- src/lib/__tests__/format-locale.test.ts | 47 +++++++++++++++++++ src/lib/format-locale.ts | 11 ++++- .../__tests__/relative-time-updated.test.ts | 35 ++++++++++++++ 9 files changed, 131 insertions(+), 26 deletions(-) diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx index 7e6797140..6f56369b2 100644 --- a/src/components/insights/sleep-hypnogram.tsx +++ b/src/components/insights/sleep-hypnogram.tsx @@ -11,7 +11,8 @@ import { } from "recharts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; +import { hourCycleOptions } from "@/lib/format-locale"; import { STAGE_COLORS } from "./sleep-stage-stacked-bar"; /** @@ -112,6 +113,7 @@ function formatMinutes(total: number, locale: string): string { export function SleepHypnogram({ session }: SleepHypnogramProps) { const { t, locale } = useTranslations(); + const timeFormat = useTimeFormatPreference(); const stageLabels: Record = { DEEP: t("insights.sleep.stages.deep"), @@ -192,8 +194,9 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) { new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", { hour: "2-digit", minute: "2-digit", + ...hourCycleOptions(timeFormat), }), - [locale], + [locale, timeFormat], ); // Per-stage breakdown with percentage of the night's total tracked time. diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx index 2ac0f7cb5..4087e4bd3 100644 --- a/src/components/insights/workout-detail.tsx +++ b/src/components/insights/workout-detail.tsx @@ -17,7 +17,11 @@ import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { cn } from "@/lib/utils"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; +import { + hourCycleOptions, + type TimeFormatPreference, +} from "@/lib/format-locale"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; /** @@ -103,6 +107,7 @@ function formatDateRange( startedAt: string, endedAt: string, locale: string, + timeFormat: TimeFormatPreference, ): string { const start = new Date(startedAt); const end = new Date(endedAt); @@ -115,6 +120,7 @@ function formatDateRange( const timeFmt = new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", + ...hourCycleOptions(timeFormat), }); return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`; } @@ -139,6 +145,7 @@ function renderSportIconBadge(sportType: string) { export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { const { t, locale } = useTranslations(); + const timeFormat = useTimeFormatPreference(); const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; const sportLabel = t(sportLabelKey); const sportName = @@ -158,7 +165,12 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { {sportName}

- {formatDateRange(workout.startedAt, workout.endedAt, locale)} + {formatDateRange( + workout.startedAt, + workout.endedAt, + locale, + timeFormat, + )}

diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index b43bbfb42..3f17d9fb5 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -13,7 +13,11 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; +import { + hourCycleOptions, + type TimeFormatPreference, +} from "@/lib/format-locale"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; /** @@ -76,7 +80,11 @@ function formatEnergy(kcal: number, locale: string): string { }).format(Math.round(kcal)); } -function formatDate(iso: string, locale: string): string { +function formatDate( + iso: string, + locale: string, + timeFormat: TimeFormatPreference, +): string { const d = new Date(iso); return new Intl.DateTimeFormat(locale, { weekday: "short", @@ -84,6 +92,7 @@ function formatDate(iso: string, locale: string): string { month: "short", hour: "2-digit", minute: "2-digit", + ...hourCycleOptions(timeFormat), }).format(d); } @@ -94,6 +103,7 @@ export interface WorkoutListProps { export function WorkoutList({ workouts, className }: WorkoutListProps) { const { t, locale } = useTranslations(); + const timeFormat = useTimeFormatPreference(); return (
    {sportName} - {formatDate(workout.startedAt, locale)} + {formatDate(workout.startedAt, locale, timeFormat)}
diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx index bc3450dbb..6b5959325 100644 --- a/src/components/settings/security-activity-card.tsx +++ b/src/components/settings/security-activity-card.tsx @@ -13,7 +13,7 @@ import { useQuery } from "@tanstack/react-query"; import { ChevronDown, Loader2, ShieldCheck } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { cn } from "@/lib/utils"; @@ -54,6 +54,7 @@ export function SecurityActivityCard({ isAuthenticated: boolean; }) { const { t } = useTranslations(); + const fmt = useFormatters(); // Collapsed by default — the feed opens only on demand, keeping the security // surface skimmable. UI-only state; nothing is persisted across reloads. const [open, setOpen] = useState(false); @@ -138,7 +139,7 @@ export function SecurityActivityCard({

- {new Date(e.createdAt).toLocaleString()} + {fmt.dateTime(e.createdAt)}

))} diff --git a/src/components/settings/security-sessions-card.tsx b/src/components/settings/security-sessions-card.tsx index 24d969551..d02b41f81 100644 --- a/src/components/settings/security-sessions-card.tsx +++ b/src/components/settings/security-sessions-card.tsx @@ -15,7 +15,7 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SettingsCard } from "@/components/settings/settings-card"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiDelete } from "@/lib/api/api-fetch"; import { cn } from "@/lib/utils"; @@ -36,6 +36,7 @@ export function SecuritySessionsCard({ isAuthenticated: boolean; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const queryClient = useQueryClient(); const [status, setStatus] = useState(null); // Collapsed by default — the list opens only when the user asks for it, so @@ -158,7 +159,7 @@ export function SecuritySessionsCard({ {s.lastActiveAt && (

{t("settings.security.lastActive", { - time: new Date(s.lastActiveAt).toLocaleString(), + time: fmt.dateTime(s.lastActiveAt), })}

)} diff --git a/src/components/ui/date-time-field.tsx b/src/components/ui/date-time-field.tsx index 09ab7fc22..d0b86c19a 100644 --- a/src/components/ui/date-time-field.tsx +++ b/src/components/ui/date-time-field.tsx @@ -10,6 +10,7 @@ import { useTimeFormatPreference, } from "@/lib/i18n/context"; import { formatDate, resolveDateLocale } from "@/lib/date-format"; +import { hourCycleOptions } from "@/lib/format-locale"; /** * Dependency-free datetime input that DISPLAYS the value in the user's @@ -221,20 +222,6 @@ function formatLocalDateTime( return `${datePart} ${timePart}`; } -/** Mirror of `format-locale.ts` `hourCycleOptions`, kept local to the field. */ -function hourCycleOptions( - timeFormat: ReturnType, -): Intl.DateTimeFormatOptions { - switch (timeFormat) { - case "H12": - return { hour12: true }; - case "H24": - return { hourCycle: "h23" }; - default: - return {}; - } -} - /** Split a local `yyyy-MM-ddTHH:mm` string into its calendar parts. */ function parseLocalDateTime(value: string): { date: string; diff --git a/src/lib/__tests__/format-locale.test.ts b/src/lib/__tests__/format-locale.test.ts index e8f727b0c..fef7c9a44 100644 --- a/src/lib/__tests__/format-locale.test.ts +++ b/src/lib/__tests__/format-locale.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { + hourCycleOptions, makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale, @@ -137,3 +138,49 @@ describe("makeFormatters", () => { expect(tokyo.date(lateUtc)).toMatch(/11/); }); }); + +// v1.25.3 — the time-format preference must reach every hour/minute renderer, +// not only the `makeFormatters().time` path. Some surfaces build their own +// `Intl.DateTimeFormat` because they render in the browser timezone (chart +// axes) or combine weekday + time in one label (workout list/detail). They now +// spread `hourCycleOptions(preference)` into the options. These tests pin both +// the helper contract and the exact call-site pattern, so a future renderer +// that drops the spread (re-introducing AM/PM under H24) fails here. +describe("hourCycleOptions", () => { + it("AUTO contributes nothing (locale default applies)", () => { + expect(hourCycleOptions("AUTO")).toEqual({}); + }); + + it("H12 forces a 12-hour clock", () => { + expect(hourCycleOptions("H12")).toEqual({ hour12: true }); + }); + + it("H24 forces the 24-hour h23 cycle", () => { + expect(hourCycleOptions("H24")).toEqual({ hourCycle: "h23" }); + }); + + // The shared call-site shape used by sleep-hypnogram, workout-list and + // workout-detail: an `en-US` formatter (AM/PM by default) with hour+minute. + const sample = new Date("2026-04-18T14:30:00Z"); // 16:30 Europe/Berlin (CEST) + const buildLabel = (preference: Parameters[0]) => + new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + hour: "2-digit", + minute: "2-digit", + ...hourCycleOptions(preference), + }).format(sample); + + it("an en-US hour/minute formatter drops AM/PM under H24", () => { + const label = buildLabel("H24"); + expect(label).not.toMatch(/[AP]M/i); + expect(label).toBe("16:30"); + }); + + it("an en-US hour/minute formatter keeps AM/PM under H12", () => { + expect(buildLabel("H12")).toMatch(/PM/); + }); + + it("an en-US hour/minute formatter follows the locale (AM/PM) under AUTO", () => { + expect(buildLabel("AUTO")).toMatch(/PM/); + }); +}); diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index cbe3f1ffe..5ddf52d65 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -61,8 +61,17 @@ export type DateFormatPreference = "AUTO" | "DMY" | "MDY" | "YMD"; /** * Intl options for the requested hour cycle. AUTO contributes nothing so * `Intl.DateTimeFormat` falls back to the locale default. + * + * Exported so call sites that build their own `Intl.DateTimeFormat` (chart + * axes, list rows that deliberately render in the browser timezone rather + * than the profile zone) can still honour the user's H12 / H24 preference by + * spreading these options. Pair it with `useTimeFormatPreference()` on the + * client. Any new hour/minute renderer must route through this rather than + * re-deciding the cycle locally — that is the regression this single source + * prevents (an `en`-locale or AUTO user got AM/PM even with H24 selected + * wherever a formatter omitted the cycle). */ -function hourCycleOptions( +export function hourCycleOptions( timeFormat: TimeFormatPreference, ): Intl.DateTimeFormatOptions { switch (timeFormat) { diff --git a/src/lib/i18n/__tests__/relative-time-updated.test.ts b/src/lib/i18n/__tests__/relative-time-updated.test.ts index 9603730ec..5198411d6 100644 --- a/src/lib/i18n/__tests__/relative-time-updated.test.ts +++ b/src/lib/i18n/__tests__/relative-time-updated.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { formatUpdatedLabel } from "@/lib/i18n/relative-time"; +import { makeFormatters } from "@/lib/format-locale"; const t = (key: string, params?: Record): string => { if (key === "insights.updatedTodayAt") @@ -54,4 +55,38 @@ describe("formatUpdatedLabel", () => { "", ); }); + + // v1.25.3 — the briefing freshness label (and the per-metric / narrative + // cards) feed `formatUpdatedLabel` the preference-aware `fmt.time`. With H24 + // selected the "today" caption must never carry AM/PM, even for an en user + // whose locale default is 12-hour. + it("renders today's time without AM/PM when fed an H24 formatter", () => { + const fmt = makeFormatters("en", "UTC", "H24"); + const now = new Date(); + const out = formatUpdatedLabel( + now.toISOString(), + t, + fmt.dateShort, + fmt.time, + "UTC", + ); + expect(out.startsWith("Updated today, ")).toBe(true); + expect(out).not.toMatch(/[AP]M/i); + }); + + it("renders today's time with AM/PM when fed an H12 formatter", () => { + const fmt = makeFormatters("en", "UTC", "H12"); + // A fixed afternoon instant so the assertion is deterministic regardless + // of when the suite runs; bucketed as "today" via the UTC day boundary. + const today = new Date(); + today.setUTCHours(14, 30, 0, 0); + const out = formatUpdatedLabel( + today.toISOString(), + t, + fmt.dateShort, + fmt.time, + "UTC", + ); + expect(out).toMatch(/PM/); + }); });