From b1cfda23639ef63a5d5be443073a75a50c865384 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 17:55:45 -0800 Subject: [PATCH 1/2] feat: improve incidents UI with browsing pages and better date formatting - Add formatIncidentRange() for smart date+time display (ongoing, same-day, cross-day) - Show "No open incidents" card with green checkmark on main page when none exist - Add "View all incidents" link on main page pointing to /incidents - Create /incidents page listing all incidents across all sites - Limit site detail page to 3 incidents with "View all" link to /sites/[id]/incidents - Create /sites/[id]/incidents page for full site-scoped incident list Closes #47 Co-Authored-By: Claude Opus 4.6 --- src/app/incidents/page.tsx | 117 ++++++++++++++++++++++++ src/app/page.tsx | 52 ++++++++--- src/app/sites/[id]/incidents/page.tsx | 127 ++++++++++++++++++++++++++ src/app/sites/[id]/page.tsx | 19 +++- src/lib/format.ts | 49 ++++++++++ 5 files changed, 348 insertions(+), 16 deletions(-) create mode 100644 src/app/incidents/page.tsx create mode 100644 src/app/sites/[id]/incidents/page.tsx diff --git a/src/app/incidents/page.tsx b/src/app/incidents/page.tsx new file mode 100644 index 0000000..a1b7f8c --- /dev/null +++ b/src/app/incidents/page.tsx @@ -0,0 +1,117 @@ +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" + +export const revalidate = 0 + +type IncidentWithSiteAndCheck = Incident & { site: Site; check: Check } + +async function getAllIncidents() { + const supabase = await createClient() + + const { data: incidents } = await supabase + .from("incidents") + .select("*, site:sites(*), check:checks(*)") + .order("status", { ascending: true }) // "open" before "resolved" + .order("opened_at", { ascending: false }) + + return (incidents ?? []) as IncidentWithSiteAndCheck[] +} + +export default async function AllIncidentsPage() { + const incidents = await getAllIncidents() + + return ( +
+
+ + ← Status + +
+ +

+ All Incidents +

+ +
+ {incidents.length === 0 ? ( +
+ No incidents recorded. +
+ ) : ( + incidents.map((incident, i) => { + const isOpen = incident.status === "open" + return ( + 0 ? "1px solid #F0EEEA" : undefined, + }} + > +
+ +
+
+
+ + {incident.site?.name} + + + {incident.status} + +
+ {incident.check?.error && ( +
+ {incident.check.error} +
+ )} +
+ {formatIncidentRange( + incident.opened_at, + incident.resolved_at + )} +
+
+ + ) + }) + )} +
+
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 1f15e2d..5b9eab8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -59,14 +59,14 @@ export default async function StatusPage() { style={{ padding: "36px 24px 80px" }} > - {incidents.length > 0 && ( -
-

- Open Incidents -

+
+

+ Open Incidents +

+ {incidents.length > 0 ? (
{incidents.map((incident) => ( ))}
-
- )} + ) : ( +
+ + + + + + + No open incidents + +
+ )} +
+ + View all incidents → + +
+
-
0 ? "44px" : "0" }}> +

+}) { + const { id } = await params + const data = await getSiteIncidents(id) + if (!data) notFound() + + const { site, incidents } = data + + return ( +
+
+ + ← {site.name} + +
+ +

+ Incidents +

+ +
+ {incidents.length === 0 ? ( +
+ No incidents recorded. +
+ ) : ( + incidents.map((incident, i) => { + const isOpen = incident.status === "open" + return ( + 0 ? "1px solid #F0EEEA" : undefined, + }} + > +
+ +
+
+
+ + {incident.check?.error ?? "Unknown error"} + + + {incident.status} + +
+
+ {formatIncidentRange( + incident.opened_at, + incident.resolved_at + )} +
+
+ + ) + }) + )} +
+
+ ) +} diff --git a/src/app/sites/[id]/page.tsx b/src/app/sites/[id]/page.tsx index a0d42d8..568c72f 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 { formatTimeAgo } from "@/lib/format" +import { formatIncidentRange } from "@/lib/format" import SiteFormDialog from "@/components/SiteFormDialog" import CheckLogTable from "@/components/CheckLogTable" @@ -118,7 +118,7 @@ export default async function SiteDetailPage({ No incidents recorded. ) : ( - incidents.map((incident, i) => { + incidents.slice(0, 3).map((incident, i) => { const isOpen = incident.status === "open" return (
- {isOpen - ? `Opened ${formatTimeAgo(incident.opened_at)}` - : `${new Date(incident.opened_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${new Date(incident.resolved_at!).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`} + {formatIncidentRange(incident.opened_at, incident.resolved_at)}
@@ -165,6 +163,17 @@ export default async function SiteDetailPage({ }) )} + {incidents.length > 3 && ( +
+ + View all incidents → + +
+ )}

diff --git a/src/lib/format.ts b/src/lib/format.ts index 24f95a1..748ad32 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -22,6 +22,55 @@ export function formatTimeAgo(dateString: string, now?: Date): string { return `${diffDays} days ago` } +/** + * Format a short time like "5:30pm" (no space, lowercase am/pm). + */ +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}` +} + +/** + * Format a short date like "Feb 19". + */ +function formatShortDate(date: Date): string { + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }) +} + +/** + * 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" + */ +export function formatIncidentRange( + openedAt: string, + resolvedAt: string | null +): string { + const start = new Date(openedAt) + const startStr = `${formatShortDate(start)} ${formatTime(start)}` + + if (!resolvedAt) { + return `${startStr} \u2013 ongoing` + } + + const end = new Date(resolvedAt) + const sameDay = + start.getFullYear() === end.getFullYear() && + start.getMonth() === end.getMonth() && + start.getDate() === end.getDate() + + if (sameDay) { + return `${startStr}\u2013${formatTime(end)}` + } + + return `${startStr} \u2013 ${formatShortDate(end)} ${formatTime(end)}` +} + /** * Format a duration from a start time to now, like "23 minutes" or "2 hours". */ From 21cb20685b0e7356963424088408ea39fe33e408 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 18:04:32 -0800 Subject: [PATCH 2/2] fix: update e2e test for always-visible Open Incidents heading The heading is now always shown with a "No open incidents" message when none exist, so update the assertion accordingly. Co-Authored-By: Claude Opus 4.6 --- e2e/incidents.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/incidents.spec.ts b/e2e/incidents.spec.ts index 4a32235..a1a1e83 100644 --- a/e2e/incidents.spec.ts +++ b/e2e/incidents.spec.ts @@ -77,5 +77,6 @@ test("resolved incident no longer appears in Open Incidents", async ({ page }) = await expect(page.getByText("Resolved", { exact: true })).toBeVisible(); await page.goto("/"); - await expect(page.getByRole("heading", { name: "Open Incidents" })).not.toBeVisible(); + await expect(page.getByRole("heading", { name: "Open Incidents" })).toBeVisible(); + await expect(page.getByText("No open incidents")).toBeVisible(); });