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
79 changes: 78 additions & 1 deletion src/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -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")

Expand Down Expand Up @@ -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")
})
})
21 changes: 3 additions & 18 deletions src/app/incidents/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -113,28 +114,12 @@ export default async function IncidentDetailPage({
<div className="mt-3 flex flex-col gap-1.5">
<div className="text-sm flex items-center gap-1.5" style={{ color: "#5C5C5C" }}>
<span>
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 <LocalDateTime dateString={incident.opened_at} />
</span>
</div>
{incident.resolved_at && (
<div className="text-sm" style={{ color: "#5C5C5C" }}>
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 <LocalDateTime dateString={incident.resolved_at} />
</div>
)}
<div className="text-sm flex items-center gap-1.5" style={{ color: "#5C5C5C" }}>
Expand Down
10 changes: 5 additions & 5 deletions src/app/incidents/page.tsx
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -101,10 +101,10 @@ export default async function AllIncidentsPage() {
</div>
)}
<div className="text-[13px]" style={{ color: "#807B74" }}>
{formatIncidentRange(
incident.opened_at,
incident.resolved_at
)}
<LocalIncidentRange
openedAt={incident.opened_at}
resolvedAt={incident.resolved_at}
/>
</div>
</div>
</Link>
Expand Down
10 changes: 5 additions & 5 deletions src/app/sites/[id]/incidents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -111,10 +111,10 @@ export default async function SiteIncidentsPage({
</span>
</div>
<div className="text-[13px]" style={{ color: "#5C5C5C" }}>
{formatIncidentRange(
incident.opened_at,
incident.resolved_at
)}
<LocalIncidentRange
openedAt={incident.opened_at}
resolvedAt={incident.resolved_at}
/>
</div>
</div>
</Link>
Expand Down
7 changes: 5 additions & 2 deletions src/app/sites/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -155,7 +155,10 @@ export default async function SiteDetailPage({
</span>
</div>
<div className="text-[13px]" style={{ color: "#5C5C5C" }}>
{formatIncidentRange(incident.opened_at, incident.resolved_at)}
<LocalIncidentRange
openedAt={incident.opened_at}
resolvedAt={incident.resolved_at}
/>
</div>
</div>
</Link>
Expand Down
12 changes: 4 additions & 8 deletions src/components/CheckLogTable.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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",
})}
<LocalDateTime dateString={check.checked_at} />
</td>
<td className="px-5 py-2.5">
<div className="flex items-center gap-2">
Expand Down
27 changes: 27 additions & 0 deletions src/components/LocalTime.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span suppressHydrationWarning>
{formatIncidentRange(openedAt, resolvedAt, tz)}
</span>
)
}

export function LocalDateTime({ dateString }: { dateString: string }) {
const tz = getTimeZone()
return (
<span suppressHydrationWarning>
{formatDateTime(dateString, tz)}
</span>
)
}
87 changes: 63 additions & 24 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}

/**
Expand Down