diff --git a/src/__tests__/timezone-explicit-formatter-guard.test.ts b/src/__tests__/timezone-explicit-formatter-guard.test.ts index ee2bff254..be1b66a56 100644 --- a/src/__tests__/timezone-explicit-formatter-guard.test.ts +++ b/src/__tests__/timezone-explicit-formatter-guard.test.ts @@ -216,6 +216,78 @@ describe("date formatters name the calendar they render in", () => { }); }); +/** + * A component renders dates through `useFormatters()`, never through the + * shared cache directly. + * + * The two-argument `getDateTimeFormat(locale, options)` from + * `@/lib/intl/formatter-cache` can name a zone and still be wrong: it takes + * the locale and the hour cycle from wherever the caller found them and + * knows nothing of the reader's date-order preference. The workout surfaces + * rebuilt their own formatters this way and showed "Dec 16" to a reader who + * had chosen YYYY-MM-DD. The three-argument `(locale, timeZone, options)` + * form from `@/lib/tz/intl-cache` builds day keys and clock parts, not + * display text, and stays allowed. + */ +describe("components format dates through useFormatters", () => { + const CALL = /\bgetDateTimeFormat\s*\(/g; + + function scan() { + const files = execFileSync( + "grep", + [ + "-rlE", + "getDateTimeFormat\\s*\\(", + "src/components", + "--include=*.ts", + "--include=*.tsx", + ], + { cwd: REPO_ROOT, encoding: "utf8" }, + ) + .trim() + .split("\n") + .filter(Boolean) + .filter((f) => !f.includes("__tests__")); + const offenders: string[] = []; + let calls = 0; + for (const file of files) { + const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); + CALL.lastIndex = 0; + for (let m = CALL.exec(source); m; m = CALL.exec(source)) { + const lineStart = source.lastIndexOf("\n", m.index) + 1; + if (/^\s*(?:\/\/|\*|\/\*)/.test(source.slice(lineStart, m.index))) + continue; + calls += 1; + const args = argumentList(source, m.index + m[0].length - 1); + if (topLevelArgCount(args) < 3) { + offenders.push( + `${file}:${source.slice(0, m.index).split("\n").length}`, + ); + } + } + } + return { offenders, calls }; + } + + it("still sees the cache's calls in components (the matcher matches)", () => { + expect(scan().calls).toBeGreaterThanOrEqual(1); + }); + + it("has no two-argument getDateTimeFormat in src/components", () => { + expect(scan().offenders).toEqual([]); + }); + + it("the matcher flags the two-argument form across lines", () => { + const source = + 'getDateTimeFormat(\n locale,\n { timeZone, month: "short" },\n)'; + CALL.lastIndex = 0; + const m = CALL.exec(source)!; + expect( + topLevelArgCount(argumentList(source, m.index + m[0].length - 1)), + ).toBe(2); + }); +}); + /** * A stated calendar date must not go through a zone-aware short formatter. * diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx index aa52aac42..6dabec782 100644 --- a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; /** @@ -17,6 +18,13 @@ vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ isAuthenticated: true }), })); +// The day label follows the reader's date order through `useFormatters()`. +const READER_FORMATTERS = makeFormatters("en", "UTC", "H24", "YMD"); +vi.mock("@/lib/i18n/context", async (importOriginal) => ({ + ...(await importOriginal()), + useFormatters: () => READER_FORMATTERS, +})); + let mockResult: { data: | { @@ -147,5 +155,8 @@ describe("", () => { expect(html).toContain("Cycling"); expect(html).toContain("30m"); expect(html).toContain("1h 0m"); + expect(html).toContain( + READER_FORMATTERS.dateWithWeekdaySmart("2026-05-15T07:00:00Z"), + ); }); }); diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index bdfa74ef4..3534fafdb 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -4,12 +4,11 @@ import Link from "next/link"; import { Activity } from "lucide-react"; import { formatDurationMinutes } from "@/lib/i18n/duration"; -import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; +import { useFormatters, 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"; import { TileHeader } from "@/components/insights/tile-header"; -import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { iconForSport } from "@/lib/workouts/sport-icons"; @@ -32,16 +31,6 @@ import { iconForSport } from "@/lib/workouts/sport-icons"; * round-trip per session. */ -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", - }).format(d); -} - function renderRow(workout: WorkoutListEntry, sportName: string) { const Icon = iconForSport(workout.sportType); return ( @@ -61,8 +50,8 @@ function renderRow(workout: WorkoutListEntry, sportName: string) { } export function RecentWorkoutsTile() { - const { t, locale } = useTranslations(); - const timeZone = useDisplayTimezone(); + const { t } = useTranslations(); + const fmt = useFormatters(); const { data, isError, error, refetch } = useWorkouts({ limit: 3 }); // A query's `isLoading` is not a hydration-safe branch: TanStack reports the @@ -148,7 +137,7 @@ export function RecentWorkoutsTile() { > {renderRow(workout, sportName)} - {formatDayLabel(workout.startedAt, locale, timeZone)} + {fmt.dateWithWeekdaySmart(workout.startedAt)} {formatDurationMinutes( diff --git a/src/components/insights/__tests__/workout-list.test.tsx b/src/components/insights/__tests__/workout-list.test.tsx index a3dad821a..abc6e4160 100644 --- a/src/components/insights/__tests__/workout-list.test.tsx +++ b/src/components/insights/__tests__/workout-list.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; +import { makeFormatters } from "@/lib/format-locale"; import { WorkoutList } from "../workout-list"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; @@ -18,6 +19,14 @@ vi.mock("@/hooks/use-auth", () => ({ useAccountOnceMounted: () => account.user, })); +// The reader's date and clock preferences reach the list through +// `useFormatters()`; this reader orders dates year first, on a 24-hour clock. +const READER_FORMATTERS = makeFormatters("en", "UTC", "H24", "YMD"); +vi.mock("@/lib/i18n/context", async (importOriginal) => ({ + ...(await importOriginal()), + useFormatters: () => READER_FORMATTERS, +})); + /** * v1.4.32 — `` unit tests. * @@ -54,6 +63,13 @@ describe("", () => { expect(html).toContain("30m"); }); + it("writes the start in the reader's own date order and clock", () => { + const html = render(); + expect(html).toContain( + `${READER_FORMATTERS.dateWithWeekdaySmart(ROW.startedAt)}, ${READER_FORMATTERS.time(ROW.startedAt)}`, + ); + }); + it("renders the distance and energy chips when present", () => { const html = render(); expect(html).toContain("5.20 km"); diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index 443ab5cd9..b87139e35 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -1,14 +1,10 @@ -import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { getNumberFormat } from "@/lib/intl/formatter-cache"; import { applyDisplayTransform, getQuantityTransform, paceSecondsPerDistanceUnit, type UnitPreference, } from "@/lib/measurements/display-transform"; -import { - hourCycleOptions, - type TimeFormatPreference, -} from "@/lib/format-locale"; /** * Shared formatters for the workout-detail surface. Pure helpers pulled @@ -120,28 +116,3 @@ export function formatPaceSeconds(secPerUnit: number, unit: string): string { } return `${m}:${s.toString().padStart(2, "0")} min/${unit}`; } - -export function formatDateRange( - startedAt: string, - 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), - }); - return `${dateFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`; -} diff --git a/src/components/insights/workout-detail/header.tsx b/src/components/insights/workout-detail/header.tsx index b82c6e218..c4b399229 100644 --- a/src/components/insights/workout-detail/header.tsx +++ b/src/components/insights/workout-detail/header.tsx @@ -1,16 +1,12 @@ "use client"; -import { - useDisplayTimezone, - useTimeFormatPreference, - useTranslations, -} from "@/lib/i18n/context"; +import { useFormatters, 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"; import { useUnitDisplay } from "@/hooks/use-unit-display"; -import { formatDuration, formatDistance, formatDateRange } from "./format"; +import { formatDuration, formatDistance } from "./format"; /** * Hero header — the at-a-glance verdict row. Sport icon badge, source @@ -35,8 +31,7 @@ function renderSportIconBadge(sportType: string) { export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { const { t, locale } = useTranslations(); - const timeFormat = useTimeFormatPreference(); - const timeZone = useDisplayTimezone(); + const fmt = useFormatters(); const { preference } = useUnitDisplay(); const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; const sportLabel = t(sportLabelKey); @@ -68,13 +63,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { {sportName}

- {formatDateRange( - workout.startedAt, - workout.endedAt, - locale, - timeFormat, - timeZone, - )} + {`${fmt.dateWithWeekdaySmart(workout.startedAt)}, ${fmt.time(workout.startedAt)}–${fmt.time(workout.endedAt)}`}

diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 3ca14f2fc..7c152a342 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -5,17 +5,9 @@ import Link from "next/link"; import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { getNumberFormat } from "@/lib/intl/formatter-cache"; import { formatDurationMinutes } from "@/lib/i18n/duration"; -import { - useDisplayTimezone, - useTimeFormatPreference, - useTranslations, -} from "@/lib/i18n/context"; -import { - hourCycleOptions, - type TimeFormatPreference, -} from "@/lib/format-locale"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; import { Button } from "@/components/ui/button"; @@ -40,30 +32,6 @@ function formatEnergy(kcal: number, locale: string): string { }).format(Math.round(kcal)); } -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 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", - ...(showYear ? { year: "numeric" as const } : {}), - hour: "2-digit", - minute: "2-digit", - ...hourCycleOptions(timeFormat), - }).format(d); -} - export interface WorkoutListProps { workouts: WorkoutListEntry[]; className?: string; @@ -82,8 +50,9 @@ export function WorkoutList({ onLoadMore, }: WorkoutListProps) { const { t, locale } = useTranslations(); - const timeFormat = useTimeFormatPreference(); - const timeZone = useDisplayTimezone(); + // The reader's date order, hour cycle and zone, as everywhere else; the + // year shows whenever the workout is not from this year. + const fmt = useFormatters(); const { preference } = useUnitDisplay(); return ( @@ -150,12 +119,7 @@ export function WorkoutList({ stays readable beside the stats column. */} - {formatDate( - workout.startedAt, - locale, - timeFormat, - timeZone, - )} + {`${fmt.dateWithWeekdaySmart(workout.startedAt)}, ${fmt.time(workout.startedAt)}`} diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index b7c5a2d55..56a5e00a5 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -14,7 +14,6 @@ import { MedicationCardBody } from "@/components/medications/card-parts/medicati import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot"; import { formatTimeWindowRange } from "@/lib/time-window-format"; import { formatDateTime, formatTime } from "@/lib/format"; -import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { getMedicationCategoryLabel } from "@/lib/medications/category-label"; import { formatDose } from "@/lib/medications/format-dose"; import { formatUnitsPerDose } from "@/lib/medications/units-per-dose"; @@ -38,7 +37,7 @@ import { LogInjectionSiteDialog } from "@/components/medications/log-injection-s import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs"; import { useAuth } from "@/hooks/use-auth"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; -import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; interface Schedule { id: string; @@ -381,22 +380,16 @@ export function MedicationCard({ : null; function formatLastTakenAt(value: string): string { - // Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and - // string-comparable for the today / yesterday / older bucketing below. - // The actual user-facing display goes through formatTime / formatDateTime. - const dayFormatter = getDateTimeFormat("en-CA", { - timeZone: userTz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); + // Day keys (YYYY-MM-DD) in the profile zone, string-comparable for the + // today / yesterday / older bucketing below. The user-facing display goes + // through formatTime / formatDateTime. const now = new Date(); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); - const intakeDay = dayFormatter.format(new Date(value)); - const todayDay = dayFormatter.format(now); - const yesterdayDay = dayFormatter.format(yesterday); + const intakeDay = userDayKey(new Date(value), userTz); + const todayDay = userDayKey(now, userTz); + const yesterdayDay = userDayKey(yesterday, userTz); const time = formatTime(value); if (intakeDay === todayDay) return `${t("medications.today")}, ${time}`;