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}`
}
/**
|