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