From a6731e064fe5b460351b15a19ee486896ffcfda6 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 19:21:31 -0800 Subject: [PATCH] feat: display dates in user's local timezone with abbreviation Detect the browser's timezone via Intl API (fallback: US Pacific) and render all dates/times in the user's local time with a short timezone abbreviation (e.g., PST, EDT). Moves date rendering into client components to access the browser timezone. Closes #50 Co-Authored-By: Claude Opus 4.6 --- src/__tests__/format.test.ts | 79 +++++++++++++++++++++++- src/app/incidents/[id]/page.tsx | 21 +------ src/app/incidents/page.tsx | 10 +-- src/app/sites/[id]/incidents/page.tsx | 10 +-- src/app/sites/[id]/page.tsx | 7 ++- src/components/CheckLogTable.tsx | 12 ++-- src/components/LocalTime.tsx | 27 +++++++++ src/lib/format.ts | 87 +++++++++++++++++++-------- 8 files changed, 190 insertions(+), 63 deletions(-) create mode 100644 src/components/LocalTime.tsx diff --git a/src/__tests__/format.test.ts b/src/__tests__/format.test.ts index 7eee453..d6cfb7d 100644 --- a/src/__tests__/format.test.ts +++ b/src/__tests__/format.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest" -import { formatTimeAgo, formatDuration } from "@/lib/format" +import { formatTimeAgo, formatDuration, formatIncidentRange, formatDateTime } from "@/lib/format" const now = new Date("2025-06-15T12:00:00Z") @@ -66,3 +66,80 @@ describe("formatDuration", () => { expect(formatDuration("2025-06-12T12:00:00Z", now)).toBe("3 days") }) }) + +describe("formatIncidentRange", () => { + test("shows ongoing with timezone abbreviation", () => { + const result = formatIncidentRange("2025-06-15T17:30:00Z", null, "America/New_York") + expect(result).toBe("Jun 15 1:30pm \u2013 ongoing EDT") + }) + + test("shows same-day range with timezone", () => { + const result = formatIncidentRange( + "2025-06-15T17:30:00Z", + "2025-06-15T18:53:00Z", + "America/New_York" + ) + expect(result).toBe("Jun 15 1:30pm\u20132:53pm EDT") + }) + + test("shows cross-day range with timezone", () => { + const result = formatIncidentRange( + "2025-06-15T23:30:00Z", + "2025-06-16T10:15:00Z", + "America/New_York" + ) + expect(result).toBe("Jun 15 7:30pm \u2013 Jun 16 6:15am EDT") + }) + + test("formats correctly in UTC", () => { + const result = formatIncidentRange("2025-06-15T17:30:00Z", null, "UTC") + expect(result).toBe("Jun 15 5:30pm \u2013 ongoing UTC") + }) + + test("formats correctly in US Pacific", () => { + const result = formatIncidentRange( + "2025-06-15T17:30:00Z", + "2025-06-15T18:53:00Z", + "America/Los_Angeles" + ) + expect(result).toBe("Jun 15 10:30am\u201311:53am PDT") + }) + + test("handles cross-day boundary due to timezone offset", () => { + // 2025-01-15 03:00 UTC = 2025-01-14 in Pacific (PST = UTC-8) + const result = formatIncidentRange( + "2025-01-15T03:00:00Z", + "2025-01-15T10:00:00Z", + "America/Los_Angeles" + ) + // Start: Jan 14 7pm, End: Jan 15 2am — cross-day in Pacific + expect(result).toBe("Jan 14 7pm \u2013 Jan 15 2am PST") + }) +}) + +describe("formatDateTime", () => { + test("formats date and time with timezone in Eastern", () => { + const result = formatDateTime("2025-06-15T17:30:00Z", "America/New_York") + expect(result).toBe("Jun 15, 1:30pm EDT") + }) + + test("formats date and time with timezone in UTC", () => { + const result = formatDateTime("2025-06-15T17:30:00Z", "UTC") + expect(result).toBe("Jun 15, 5:30pm UTC") + }) + + test("formats date and time with timezone in Pacific", () => { + const result = formatDateTime("2025-06-15T17:30:00Z", "America/Los_Angeles") + expect(result).toBe("Jun 15, 10:30am PDT") + }) + + test("omits minutes when on the hour", () => { + const result = formatDateTime("2025-06-15T17:00:00Z", "UTC") + expect(result).toBe("Jun 15, 5pm UTC") + }) + + test("shows winter timezone abbreviation (PST vs PDT)", () => { + const result = formatDateTime("2025-01-15T20:00:00Z", "America/Los_Angeles") + expect(result).toBe("Jan 15, 12pm PST") + }) +}) diff --git a/src/app/incidents/[id]/page.tsx b/src/app/incidents/[id]/page.tsx index c850a2a..d708242 100644 --- a/src/app/incidents/[id]/page.tsx +++ b/src/app/incidents/[id]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation" import { createClient } from "@/lib/supabase/server" import type { Incident, Site, Check } from "@/lib/supabase/types" import CheckLogTable from "@/components/CheckLogTable" +import { LocalDateTime } from "@/components/LocalTime" import { resolveIncident } from "./actions" export const revalidate = 0 @@ -113,28 +114,12 @@ export default async function IncidentDetailPage({
- Opened{" "} - {new Date(incident.opened_at).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })},{" "} - {new Date(incident.opened_at).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} + Opened
{incident.resolved_at && (
- Resolved{" "} - {new Date(incident.resolved_at).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })},{" "} - {new Date(incident.resolved_at).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} + Resolved
)}
diff --git a/src/app/incidents/page.tsx b/src/app/incidents/page.tsx index a1b7f8c..8b841ed 100644 --- a/src/app/incidents/page.tsx +++ b/src/app/incidents/page.tsx @@ -1,7 +1,7 @@ import Link from "next/link" import { createClient } from "@/lib/supabase/server" import type { Site, Check, Incident } from "@/lib/supabase/types" -import { formatIncidentRange } from "@/lib/format" +import { LocalIncidentRange } from "@/components/LocalTime" export const revalidate = 0 @@ -101,10 +101,10 @@ export default async function AllIncidentsPage() {
)}
- {formatIncidentRange( - incident.opened_at, - incident.resolved_at - )} +
diff --git a/src/app/sites/[id]/incidents/page.tsx b/src/app/sites/[id]/incidents/page.tsx index 104d482..cb7a289 100644 --- a/src/app/sites/[id]/incidents/page.tsx +++ b/src/app/sites/[id]/incidents/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link" import { notFound } from "next/navigation" import { createClient } from "@/lib/supabase/server" import type { Site, Check, Incident } from "@/lib/supabase/types" -import { formatIncidentRange } from "@/lib/format" +import { LocalIncidentRange } from "@/components/LocalTime" export const revalidate = 0 @@ -111,10 +111,10 @@ export default async function SiteIncidentsPage({
- {formatIncidentRange( - incident.opened_at, - incident.resolved_at - )} +
diff --git a/src/app/sites/[id]/page.tsx b/src/app/sites/[id]/page.tsx index 568c72f..e750f79 100644 --- a/src/app/sites/[id]/page.tsx +++ b/src/app/sites/[id]/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link" import { notFound } from "next/navigation" import { createClient } from "@/lib/supabase/server" import type { Site, Check, Incident } from "@/lib/supabase/types" -import { formatIncidentRange } from "@/lib/format" +import { LocalIncidentRange } from "@/components/LocalTime" import SiteFormDialog from "@/components/SiteFormDialog" import CheckLogTable from "@/components/CheckLogTable" @@ -155,7 +155,10 @@ export default async function SiteDetailPage({
- {formatIncidentRange(incident.opened_at, incident.resolved_at)} +
diff --git a/src/components/CheckLogTable.tsx b/src/components/CheckLogTable.tsx index a111c3d..f5c61a6 100644 --- a/src/components/CheckLogTable.tsx +++ b/src/components/CheckLogTable.tsx @@ -1,5 +1,8 @@ +"use client" + import type { Check } from "@/lib/supabase/types" import { isSoftFailure } from "@/lib/checker" +import { LocalDateTime } from "@/components/LocalTime" function checkDotColor(check: Check): string { if (check.status !== "failure") return "#2DA44E" @@ -70,14 +73,7 @@ export default function CheckLogTable({ className="px-5 py-2.5 text-sm whitespace-nowrap" style={{ color: "#5C5C5C", fontVariantNumeric: "tabular-nums" }} > - {new Date(check.checked_at).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - })}{" "} - {new Date(check.checked_at).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} +
diff --git a/src/components/LocalTime.tsx b/src/components/LocalTime.tsx new file mode 100644 index 0000000..76db00c --- /dev/null +++ b/src/components/LocalTime.tsx @@ -0,0 +1,27 @@ +"use client" + +import { getTimeZone, formatIncidentRange, formatDateTime } from "@/lib/format" + +export function LocalIncidentRange({ + openedAt, + resolvedAt, +}: { + openedAt: string + resolvedAt: string | null +}) { + const tz = getTimeZone() + return ( + + {formatIncidentRange(openedAt, resolvedAt, tz)} + + ) +} + +export function LocalDateTime({ dateString }: { dateString: string }) { + const tz = getTimeZone() + return ( + + {formatDateTime(dateString, tz)} + + ) +} diff --git a/src/lib/format.ts b/src/lib/format.ts index 748ad32..37a3d00 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -23,52 +23,91 @@ export function formatTimeAgo(dateString: string, now?: Date): string { } /** - * Format a short time like "5:30pm" (no space, lowercase am/pm). + * Get the user's timezone, falling back to US Pacific. */ -function formatTime(date: Date): string { - const hours = date.getHours() - const minutes = date.getMinutes() - const ampm = hours >= 12 ? "pm" : "am" - const h = hours % 12 || 12 - const m = minutes === 0 ? "" : `:${minutes.toString().padStart(2, "0")}` - return `${h}${m}${ampm}` +export function getTimeZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "America/Los_Angeles" + } catch { + return "America/Los_Angeles" + } +} + +/** + * Get a short timezone abbreviation like "PST" or "EST" for the given date and timezone. + */ +function getTimeZoneAbbr(date: Date, timeZone: string): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: "short", + }).formatToParts(date) + return parts.find((p) => p.type === "timeZoneName")?.value ?? "" +} + +/** + * Format a short time like "5:30pm" (no space, lowercase am/pm) in the given timezone. + */ +function formatTime(date: Date, timeZone: string): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "numeric", + minute: "2-digit", + hour12: true, + }).formatToParts(date) + + const hour = parts.find((p) => p.type === "hour")?.value ?? "12" + const minute = parts.find((p) => p.type === "minute")?.value ?? "00" + const dayPeriod = (parts.find((p) => p.type === "dayPeriod")?.value ?? "AM").toLowerCase() + + const m = minute === "00" ? "" : `:${minute}` + return `${hour}${m}${dayPeriod}` } /** - * Format a short date like "Feb 19". + * Format a short date like "Feb 19" in the given timezone. */ -function formatShortDate(date: Date): string { - return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }) +function formatShortDate(date: Date, timeZone: string): string { + return date.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone }) } /** - * Format an incident date range with smart date+time display: - * - Open: "Feb 19 5:30pm – ongoing" - * - Same-day resolved: "Feb 19 5:30pm–5:53pm" - * - Cross-day resolved: "Feb 19 5:30pm – Feb 20 10:15am" + * Format an incident date range with smart date+time display and timezone abbreviation: + * - Open: "Feb 19 5:30pm – ongoing PST" + * - Same-day resolved: "Feb 19 5:30pm–5:53pm PST" + * - Cross-day resolved: "Feb 19 5:30pm – Feb 20 10:15am PST" */ export function formatIncidentRange( openedAt: string, - resolvedAt: string | null + resolvedAt: string | null, + timeZone: string ): string { const start = new Date(openedAt) - const startStr = `${formatShortDate(start)} ${formatTime(start)}` + const tz = getTimeZoneAbbr(start, timeZone) + const startStr = `${formatShortDate(start, timeZone)} ${formatTime(start, timeZone)}` if (!resolvedAt) { - return `${startStr} \u2013 ongoing` + return `${startStr} \u2013 ongoing ${tz}` } const end = new Date(resolvedAt) - const sameDay = - start.getFullYear() === end.getFullYear() && - start.getMonth() === end.getMonth() && - start.getDate() === end.getDate() + const startDateStr = formatShortDate(start, timeZone) + const endDateStr = formatShortDate(end, timeZone) + const sameDay = startDateStr === endDateStr if (sameDay) { - return `${startStr}\u2013${formatTime(end)}` + return `${startStr}\u2013${formatTime(end, timeZone)} ${tz}` } - return `${startStr} \u2013 ${formatShortDate(end)} ${formatTime(end)}` + return `${startStr} \u2013 ${endDateStr} ${formatTime(end, timeZone)} ${tz}` +} + +/** + * Format a date string as "Feb 19, 5:30pm PST" in the given timezone. + */ +export function formatDateTime(dateString: string, timeZone: string): string { + const date = new Date(dateString) + const tz = getTimeZoneAbbr(date, timeZone) + return `${formatShortDate(date, timeZone)}, ${formatTime(date, timeZone)} ${tz}` } /**