Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions src/app/memos/[slug]/MemoEngagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 } {
Expand Down
9 changes: 3 additions & 6 deletions src/app/memos/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { formatEditorialDate } from "@/lib/date-format";

export interface MemoItem {
id: string;
title: string;
Expand All @@ -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 {
Expand Down
9 changes: 2 additions & 7 deletions src/app/state-of-the-nation/canvas/OverlayChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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[] }) {
Expand Down
11 changes: 3 additions & 8 deletions src/app/state-of-the-nation/og-card.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -78,14 +79,8 @@
// 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));
}
Expand Down Expand Up @@ -374,7 +369,7 @@
) : null}
</div>

<img

Check warning on line 372 in src/app/state-of-the-nation/og-card.tsx

View workflow job for this annotation

GitHub Actions / lint

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src={chart.dataUri}
width={CHART_W}
height={CHART_H}
Expand Down
8 changes: 3 additions & 5 deletions src/components/feed/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { formatEditorialDate } from "@/lib/date-format";

export interface FeedItem {
id: string;
type: string;
Expand Down Expand Up @@ -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);
}
10 changes: 2 additions & 8 deletions src/components/ui/memo-card.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
71 changes: 71 additions & 0 deletions src/lib/date-format.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading