From ff3559c1695ffedc7189c0de7a1306f85c3b8d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 14:56:53 +0200 Subject: [PATCH] fix(workouts): render workout dates in the profile timezone The workout list, the workout header and the dashboard tile formatted instants through the shared formatter cache without a timeZone, so they followed the browser's zone. The year comparison in the list used the browser's calendar as well. All three now take the profile zone, and the timezone guard also matches getDateTimeFormat(locale, options) calls that leave the zone out. --- .../timezone-explicit-formatter-guard.test.ts | 36 +++++++++++++++++-- .../dashboard/recent-workouts-tile.tsx | 8 +++-- .../insights/workout-detail/format.ts | 3 ++ .../insights/workout-detail/header.tsx | 8 ++++- src/components/insights/workout-list.tsx | 20 +++++++++-- src/lib/format-locale.ts | 9 +++-- 6 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/__tests__/timezone-explicit-formatter-guard.test.ts b/src/__tests__/timezone-explicit-formatter-guard.test.ts index 9eaf1456e..ee2bff254 100644 --- a/src/__tests__/timezone-explicit-formatter-guard.test.ts +++ b/src/__tests__/timezone-explicit-formatter-guard.test.ts @@ -51,6 +51,11 @@ const ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ const FORMATTER_PATTERNS = [ /new Intl\.DateTimeFormat\s*\(/g, /\.toLocale(?:Date|Time)String\s*\(/g, + // The shared cache's own entry point: it forwards its options straight to + // `Intl.DateTimeFormat`, so a call that omits the zone is a bare formatter + // exactly like the constructor form (the workout surfaces did this until + // v1.43.6). + /\bgetDateTimeFormat\s*\(/g, ]; /** The argument list starting at the `(` at or after `from`. */ @@ -68,12 +73,30 @@ function argumentList(source: string, from: number): string { return source.slice(open); } +/** Number of top-level, comma-separated arguments in `(…)`. */ +function topLevelArgCount(args: string): number { + let depth = 0; + let count = 0; + let sawContent = false; + for (const ch of args.slice(1, -1)) { + if ("([{".includes(ch)) depth += 1; + else if (")]}".includes(ch)) depth -= 1; + else if (ch === "," && depth === 0) { + count += 1; + sawContent = false; + continue; + } + if (!/\s/.test(ch)) sawContent = true; + } + return sawContent ? count + 1 : count; +} + function bareFormatterSites(): Array<{ file: string; line: number }> { const listed = execFileSync( "grep", [ "-rlE", - "new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(", + "new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(|getDateTimeFormat\\(", "src", "--include=*.ts", "--include=*.tsx", @@ -105,6 +128,15 @@ function bareFormatterSites(): Array<{ file: string; line: number }> { // of them and the honest response would be to allowlist them — which // is how a guard stops guarding. if (/\btimeZone\s*(?::|,|\}|$)/m.test(args)) continue; + // `getDateTimeFormat(locale, tz, options)` takes the zone as its own + // argument; only the two-argument `(locale, options)` form can leave + // it out. + if (m[0].startsWith("getDateTimeFormat") && topLevelArgCount(args) >= 3) + continue; + // A mention in a comment renders nothing. + const lineStart = source.lastIndexOf("\n", m.index) + 1; + if (/^\s*(?:\/\/|\*|\/\*)/.test(source.slice(lineStart, m.index))) + continue; hits.push({ file, line: source.slice(0, m.index).split("\n").length, @@ -125,7 +157,7 @@ describe("date formatters name the calendar they render in", () => { "grep", [ "-rlE", - "new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(", + "new Intl\\.DateTimeFormat|\\.toLocale(Date|Time)String\\(|getDateTimeFormat\\(", "src", "--include=*.ts", "--include=*.tsx", diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index 60c094a15..ec0f87625 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { Activity } from "lucide-react"; import { formatDurationMinutes } from "@/lib/i18n/duration"; -import { useTranslations } from "@/lib/i18n/context"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; import { cn } from "@/lib/utils"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; @@ -32,9 +32,10 @@ import { iconForSport } from "@/lib/workouts/sport-icons"; * round-trip per session. */ -function formatDayLabel(iso: string, locale: string): string { +function formatDayLabel(iso: string, locale: string, timeZone: string): string { const d = new Date(iso); return getDateTimeFormat(locale, { + timeZone, weekday: "short", day: "2-digit", month: "short", @@ -61,6 +62,7 @@ function renderRow(workout: WorkoutListEntry, sportName: string) { export function RecentWorkoutsTile() { const { t, locale } = useTranslations(); + const timeZone = useDisplayTimezone(); const { data, isError, refetch } = useWorkouts({ limit: 3 }); // A query's `isLoading` is not a hydration-safe branch: TanStack reports the @@ -145,7 +147,7 @@ export function RecentWorkoutsTile() { > {renderRow(workout, sportName)} - {formatDayLabel(workout.startedAt, locale)} + {formatDayLabel(workout.startedAt, locale, timeZone)} {formatDurationMinutes( diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index a3027ea5d..443ab5cd9 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -126,16 +126,19 @@ export function formatDateRange( endedAt: string, locale: string, timeFormat: TimeFormatPreference, + timeZone: string, ): string { const start = new Date(startedAt); const end = new Date(endedAt); const dateFmt = getDateTimeFormat(locale, { + timeZone, weekday: "long", day: "2-digit", month: "long", year: "numeric", }); const timeFmt = getDateTimeFormat(locale, { + timeZone, hour: "2-digit", minute: "2-digit", ...hourCycleOptions(timeFormat), diff --git a/src/components/insights/workout-detail/header.tsx b/src/components/insights/workout-detail/header.tsx index 1e4200f0b..b82c6e218 100644 --- a/src/components/insights/workout-detail/header.tsx +++ b/src/components/insights/workout-detail/header.tsx @@ -1,6 +1,10 @@ "use client"; -import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; +import { + useDisplayTimezone, + useTimeFormatPreference, + useTranslations, +} 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"; @@ -32,6 +36,7 @@ function renderSportIconBadge(sportType: string) { export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { const { t, locale } = useTranslations(); const timeFormat = useTimeFormatPreference(); + const timeZone = useDisplayTimezone(); const { preference } = useUnitDisplay(); const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; const sportLabel = t(sportLabelKey); @@ -68,6 +73,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { workout.endedAt, locale, timeFormat, + timeZone, )}

diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 913a19846..3ca14f2fc 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -7,7 +7,11 @@ 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 { + useDisplayTimezone, + useTimeFormatPreference, + useTranslations, +} from "@/lib/i18n/context"; import { hourCycleOptions, type TimeFormatPreference, @@ -40,12 +44,16 @@ function formatDate( iso: string, locale: string, timeFormat: TimeFormatPreference, + timeZone: string, ): string { const d = new Date(iso); // Show the year whenever the workout is not from the current year, so a // "Do., 16. Dez." from a previous year is never mistaken for this year's. - const showYear = d.getFullYear() !== new Date().getFullYear(); + const yearOf = (value: Date) => + getDateTimeFormat(locale, { timeZone, year: "numeric" }).format(value); + const showYear = yearOf(d) !== yearOf(new Date()); return getDateTimeFormat(locale, { + timeZone, weekday: "short", day: "2-digit", month: "short", @@ -75,6 +83,7 @@ export function WorkoutList({ }: WorkoutListProps) { const { t, locale } = useTranslations(); const timeFormat = useTimeFormatPreference(); + const timeZone = useDisplayTimezone(); const { preference } = useUnitDisplay(); return ( @@ -141,7 +150,12 @@ export function WorkoutList({ stays readable beside the stats column. */} - {formatDate(workout.startedAt, locale, timeFormat)} + {formatDate( + workout.startedAt, + locale, + timeFormat, + timeZone, + )} diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index 5186efdf0..68548ea40 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -124,9 +124,12 @@ function asDate(value: DateInput): Date { * lookup is cheap. */ function isCurrentYearInTz(date: Date, tz: string, localeTag: string): boolean { - const yearOnly = { timeZone: tz, year: "numeric" as const }; - const target = getDateTimeFormat(localeTag, yearOnly).format(date); - const now = getDateTimeFormat(localeTag, yearOnly).format(new Date()); + const yearIn = (value: Date) => + getDateTimeFormat(localeTag, { timeZone: tz, year: "numeric" }).format( + value, + ); + const target = yearIn(date); + const now = yearIn(new Date()); return target === now; }