diff --git a/src/app/memos/[slug]/MemoEngagement.tsx b/src/app/memos/[slug]/MemoEngagement.tsx index 2a5a0561..2208983c 100644 --- a/src/app/memos/[slug]/MemoEngagement.tsx +++ b/src/app/memos/[slug]/MemoEngagement.tsx @@ -5,6 +5,7 @@ import { Dialog } from "@base-ui/react/dialog"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { formatEditorialLongDate } from "@/lib/date-format"; type Endorser = { name: string; created_at: string }; type Critique = { id: number; name: string; body: string; created_at: string }; @@ -47,16 +48,7 @@ async function postJson( } function formatDate(iso: string) { - try { - return new Date(iso).toLocaleDateString("en-CA", { - year: "numeric", - month: "long", - day: "numeric", - timeZone: "UTC", - }); - } catch { - return ""; - } + return formatEditorialLongDate(iso); } function splitFirstSentence(text: string): { first: string; rest: string } { diff --git a/src/app/memos/types.ts b/src/app/memos/types.ts index b50b6f98..e7641fe9 100644 --- a/src/app/memos/types.ts +++ b/src/app/memos/types.ts @@ -1,3 +1,5 @@ +import { formatEditorialDate } from "@/lib/date-format"; + export interface MemoItem { id: string; title: string; @@ -19,12 +21,7 @@ export interface MemoItem { } export function formatDate(dateStr: string | null, fallback: string) { - const d = new Date(dateStr || fallback); - return d.toLocaleDateString("en-CA", { - year: "numeric", - month: "short", - day: "numeric", - }); + return formatEditorialDate(dateStr || fallback); } export function shortenName(name: string): string { diff --git a/src/app/state-of-the-nation/canvas/OverlayChart.tsx b/src/app/state-of-the-nation/canvas/OverlayChart.tsx index 616df886..804ca715 100644 --- a/src/app/state-of-the-nation/canvas/OverlayChart.tsx +++ b/src/app/state-of-the-nation/canvas/OverlayChart.tsx @@ -12,6 +12,7 @@ import { } from "chart.js"; import { axisLabel, formatValue } from "../units"; import type { OverlaySeries } from "./overlay-types"; +import { formatEditorialMonth } from "@/lib/date-format"; // Raw-value overlay: one y-axis per feed, because feeds carry different units // (a rate against a dollar figure against a count). This is the one canvas @@ -29,14 +30,8 @@ ChartJS.register( Legend, ); -const MONTH_FORMAT = new Intl.DateTimeFormat("en-CA", { - month: "short", - year: "numeric", - timeZone: "UTC", -}); - function formatMonth(isoDate: string): string { - return MONTH_FORMAT.format(new Date(isoDate)); + return formatEditorialMonth(isoDate); } export default function OverlayChart({ series }: { series: OverlaySeries[] }) { diff --git a/src/app/state-of-the-nation/og-card.tsx b/src/app/state-of-the-nation/og-card.tsx index 7e596079..57045137 100644 --- a/src/app/state-of-the-nation/og-card.tsx +++ b/src/app/state-of-the-nation/og-card.tsx @@ -1,4 +1,5 @@ import type { EconomySeriesResponse } from "@/lib/api/economy"; +import { formatEditorialMonth } from "@/lib/date-format"; import { CANADA_COLOR } from "./indicators"; import { formatValue } from "./units"; @@ -78,14 +79,8 @@ function endDot(x: number, y: number, fill: string): string { // Monthly points carry an ISO first-of-month date; annual points only a year. function timeLabel(point: { year: number; date?: string }): string { if (point.date) { - const parsed = new Date(point.date); - if (!Number.isNaN(parsed.getTime())) { - return new Intl.DateTimeFormat("en-CA", { - month: "short", - year: "numeric", - timeZone: "UTC", - }).format(parsed); - } + const formatted = formatEditorialMonth(point.date); + if (formatted) return formatted; } return String(Math.floor(point.year)); } diff --git a/src/components/feed/types.ts b/src/components/feed/types.ts index 3e942c6f..89086d78 100644 --- a/src/components/feed/types.ts +++ b/src/components/feed/types.ts @@ -1,3 +1,5 @@ +import { formatEditorialDate } from "@/lib/date-format"; + export interface FeedItem { id: string; type: string; @@ -47,9 +49,5 @@ export function isIGVideo(item: FeedItem): boolean { } export function formatFeedDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); + return formatEditorialDate(dateStr); } diff --git a/src/components/ui/memo-card.tsx b/src/components/ui/memo-card.tsx index f76bca8b..66165e72 100644 --- a/src/components/ui/memo-card.tsx +++ b/src/components/ui/memo-card.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import Image from "next/image"; import { cn } from "@/lib/utils"; +import { formatEditorialDate } from "@/lib/date-format"; export interface Memo { id: string; @@ -28,14 +29,7 @@ export function formatCategory(category: string | null | undefined): string { } function formatDate(dateStr: string | null | undefined, fallback?: string): string { - const d = new Date(dateStr || fallback || ''); - if (isNaN(d.getTime())) return ''; - return d.toLocaleDateString("en-CA", { - timeZone: "America/Toronto", - year: "numeric", - month: "short", - day: "numeric", - }); + return formatEditorialDate(dateStr || fallback); } interface CardCTAProps { diff --git a/src/lib/date-format.ts b/src/lib/date-format.ts new file mode 100644 index 00000000..ec30883f --- /dev/null +++ b/src/lib/date-format.ts @@ -0,0 +1,71 @@ +export const EDITORIAL_TIME_ZONE = "America/Toronto"; + +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function parseEditorialDate(value: string | Date): Date { + if (value instanceof Date) return value; + + // A date-only string is a calendar label, not a UTC instant. Noon UTC stays + // on the same calendar day in Toronto, including across DST transitions. + return new Date(DATE_ONLY_PATTERN.test(value) ? `${value}T12:00:00Z` : value); +} + +const editorialDateFormatter = new Intl.DateTimeFormat("en-CA", { + timeZone: EDITORIAL_TIME_ZONE, + year: "numeric", + month: "short", + day: "numeric", +}); + +const editorialLongDateFormatter = new Intl.DateTimeFormat("en-CA", { + timeZone: EDITORIAL_TIME_ZONE, + year: "numeric", + month: "long", + day: "numeric", +}); + +const editorialMonthFormatter = new Intl.DateTimeFormat("en-CA", { + timeZone: EDITORIAL_TIME_ZONE, + month: "short", + year: "numeric", +}); + +/** + * Format editorial timestamps identically during SSR and browser hydration. + * + * Without an explicit time zone, the server and browser can render different + * calendar days for the same timestamp, which causes a React text hydration + * mismatch. + */ +export function formatEditorialDate( + value: string | Date | null | undefined, +): string { + if (!value) return ""; + + const date = parseEditorialDate(value); + if (Number.isNaN(date.getTime())) return ""; + + return editorialDateFormatter.format(date); +} + +export function formatEditorialLongDate( + value: string | Date | null | undefined, +): string { + if (!value) return ""; + + const date = parseEditorialDate(value); + if (Number.isNaN(date.getTime())) return ""; + + return editorialLongDateFormatter.format(date); +} + +export function formatEditorialMonth( + value: string | Date | null | undefined, +): string { + if (!value) return ""; + + const date = parseEditorialDate(value); + if (Number.isNaN(date.getTime())) return ""; + + return editorialMonthFormatter.format(date); +}