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
4 changes: 2 additions & 2 deletions e2e/home.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";

test("homepage has heading", async ({ page }) => {
test("homepage has Sites heading", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Site Status" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Sites" })).toBeVisible();
});
68 changes: 68 additions & 0 deletions src/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, test, expect } from "vitest"
import { formatTimeAgo, formatDuration } from "@/lib/format"

const now = new Date("2025-06-15T12:00:00Z")

describe("formatTimeAgo", () => {
test("returns 'just now' for times less than 60 seconds ago", () => {
expect(formatTimeAgo("2025-06-15T11:59:30Z", now)).toBe("just now")
})

test("returns '1 min ago' for exactly 1 minute", () => {
expect(formatTimeAgo("2025-06-15T11:59:00Z", now)).toBe("1 min ago")
})

test("returns 'N min ago' for minutes", () => {
expect(formatTimeAgo("2025-06-15T11:55:00Z", now)).toBe("5 min ago")
})

test("returns '1 hr ago' for exactly 1 hour", () => {
expect(formatTimeAgo("2025-06-15T11:00:00Z", now)).toBe("1 hr ago")
})

test("returns 'N hrs ago' for hours", () => {
expect(formatTimeAgo("2025-06-15T09:00:00Z", now)).toBe("3 hrs ago")
})

test("returns '1 day ago' for exactly 1 day", () => {
expect(formatTimeAgo("2025-06-14T12:00:00Z", now)).toBe("1 day ago")
})

test("returns 'N days ago' for multiple days", () => {
expect(formatTimeAgo("2025-06-12T12:00:00Z", now)).toBe("3 days ago")
})

test("returns 'just now' for future dates", () => {
expect(formatTimeAgo("2025-06-15T13:00:00Z", now)).toBe("just now")
})
})

describe("formatDuration", () => {
test("returns '0 minutes' for less than a minute", () => {
expect(formatDuration("2025-06-15T11:59:30Z", now)).toBe("0 minutes")
})

test("returns '1 minute' for exactly 1 minute", () => {
expect(formatDuration("2025-06-15T11:59:00Z", now)).toBe("1 minute")
})

test("returns 'N minutes' for minutes", () => {
expect(formatDuration("2025-06-15T11:37:00Z", now)).toBe("23 minutes")
})

test("returns '1 hour' for exactly 1 hour", () => {
expect(formatDuration("2025-06-15T11:00:00Z", now)).toBe("1 hour")
})

test("returns 'N hours' for multiple hours", () => {
expect(formatDuration("2025-06-15T09:00:00Z", now)).toBe("3 hours")
})

test("returns '1 day' for exactly 1 day", () => {
expect(formatDuration("2025-06-14T12:00:00Z", now)).toBe("1 day")
})

test("returns 'N days' for multiple days", () => {
expect(formatDuration("2025-06-12T12:00:00Z", now)).toBe("3 days")
})
})
102 changes: 102 additions & 0 deletions src/__tests__/status-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, test, expect, vi } from "vitest"

// Mock next/headers
vi.mock("next/headers", () => ({
cookies: () =>
Promise.resolve({
getAll: () => [],
set: vi.fn(),
}),
}))

// Mock Supabase server client
const mockFrom = vi.fn()
vi.mock("@/lib/supabase/server", () => ({
createClient: () =>
Promise.resolve({
from: mockFrom,
}),
}))

describe("status page data fetching", () => {
test("format module exports are available", async () => {
const format = await import("@/lib/format")
expect(typeof format.formatTimeAgo).toBe("function")
expect(typeof format.formatDuration).toBe("function")
})

test("types module exports are available", async () => {
// Just verify the types module can be imported
const types = await import("@/lib/supabase/types")
expect(types).toBeDefined()
})

test("supabase query chain for sites works with mock", () => {
const mockSelect = vi.fn().mockReturnValue({
order: vi.fn().mockReturnValue({
data: [
{ id: "site-1", name: "Main", url: "https://example.com", created_at: "2025-01-01" },
],
error: null,
}),
})
mockFrom.mockReturnValue({ select: mockSelect })

const result = mockFrom("sites").select("*").order("name")
expect(result.data).toHaveLength(1)
expect(result.data[0].name).toBe("Main")
})

test("supabase query chain for incidents works with mock", () => {
const mockEq = vi.fn().mockReturnValue({
order: vi.fn().mockReturnValue({
data: [
{
id: "inc-1",
site_id: "site-1",
status: "open",
opened_at: "2025-01-01",
site: { id: "site-1", name: "Main", url: "https://example.com" },
check: { id: "check-1", error: "HTTP 503" },
},
],
error: null,
}),
})
const mockSelect = vi.fn().mockReturnValue({ eq: mockEq })
mockFrom.mockReturnValue({ select: mockSelect })

const result = mockFrom("incidents")
.select("*, site:sites(*), check:checks(*)")
.eq("status", "open")
.order("opened_at", { ascending: false })
expect(result.data).toHaveLength(1)
expect(result.data[0].site.name).toBe("Main")
})

test("supabase query chain for latest check works with mock", () => {
const mockLimit = vi.fn().mockReturnValue({
data: [
{
id: "check-1",
site_id: "site-1",
status: "success",
checked_at: "2025-01-01T12:00:00Z",
},
],
error: null,
})
const mockOrder = vi.fn().mockReturnValue({ limit: mockLimit })
const mockEq = vi.fn().mockReturnValue({ order: mockOrder })
const mockSelect = vi.fn().mockReturnValue({ eq: mockEq })
mockFrom.mockReturnValue({ select: mockSelect })

const result = mockFrom("checks")
.select("*")
.eq("site_id", "site-1")
.order("checked_at", { ascending: false })
.limit(1)
expect(result.data).toHaveLength(1)
expect(result.data[0].status).toBe("success")
})
})
182 changes: 175 additions & 7 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,178 @@
export default function Home() {
import Link from "next/link"
import { createClient } from "@/lib/supabase/server"
import type { Site, Check, Incident } from "@/lib/supabase/types"
import { formatTimeAgo, formatDuration } from "@/lib/format"

export const revalidate = 0

type SiteWithLastCheck = Site & { lastCheck: Check | null }
type IncidentWithSite = Incident & { site: Site; check: Check }

async function getStatusData() {
const supabase = await createClient()

const { data: sites } = await supabase
.from("sites")
.select("*")
.order("name")

const { data: openIncidents } = await supabase
.from("incidents")
.select("*, site:sites(*), check:checks(*)")
.eq("status", "open")
.order("opened_at", { ascending: false })

const sitesWithChecks: SiteWithLastCheck[] = []
for (const site of sites ?? []) {
const { data: checks } = await supabase
.from("checks")
.select("*")
.eq("site_id", site.id)
.order("checked_at", { ascending: false })
.limit(1)

sitesWithChecks.push({
...site,
lastCheck: checks?.[0] ?? null,
})
}

return {
sites: sitesWithChecks,
incidents: (openIncidents ?? []) as IncidentWithSite[],
}
}

export default async function StatusPage() {
const { sites, incidents } = await getStatusData()

return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<h1 className="text-4xl font-bold">Site Status</h1>
<p className="mt-4 text-lg text-gray-600">
Uptime monitoring for Roots of Progress
</p>
<main
className="max-w-[960px] mx-auto"
style={{ padding: "36px 24px 80px" }}
>
{incidents.length > 0 && (
<section>
<h2
className="text-xl font-bold mb-4"
style={{ color: "#1A1A1A" }}
>
Open Incidents
</h2>
<div className="flex flex-col gap-3">
{incidents.map((incident) => (
<Link
key={incident.id}
href={`/incidents/${incident.id}`}
className="block no-underline"
style={{
backgroundColor: "#FFFFFF",
border: "1px solid #E8E4DF",
borderLeft: "3px solid #C4453C",
borderRadius: "4px",
padding: "16px 20px",
color: "inherit",
}}
>
<div className="flex items-baseline justify-between gap-3 flex-wrap">
<span
className="text-[15px] font-bold"
style={{ color: "#1A1A1A" }}
>
{incident.site?.name}
</span>
<span
className="text-[13px] font-semibold"
style={{ color: "#C4453C" }}
>
Down for {formatDuration(incident.opened_at)}
</span>
</div>
<div className="mt-1.5 flex items-center gap-3 flex-wrap">
{incident.check?.error && (
<span
className="text-xs font-semibold px-2 py-0.5 rounded"
style={{
color: "#5C5C5C",
backgroundColor: "#F3F0EC",
}}
>
{incident.check.error}
</span>
)}
<span className="text-[13px]" style={{ color: "#5C5C5C" }}>
{incident.site?.url}
</span>
</div>
</Link>
))}
</div>
</section>
)}

<section style={{ marginTop: incidents.length > 0 ? "44px" : "0" }}>
<h2
className="text-xl font-bold mb-4"
style={{ color: "#1A1A1A" }}
>
Sites
</h2>

{sites.length === 0 ? (
<p className="text-sm" style={{ color: "#5C5C5C" }}>
No sites are being monitored yet.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{sites.map((site) => (
<Link
key={site.id}
href={`/sites/${site.id}`}
className="block no-underline"
style={{
backgroundColor: "#FFFFFF",
border: "1px solid #E8E4DF",
borderRadius: "4px",
padding: "16px 18px",
color: "inherit",
}}
>
<div className="flex items-center gap-2.5">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{
backgroundColor:
site.lastCheck?.status === "failure"
? "#C4453C"
: "#5A8A5A",
}}
/>
<span
className="text-sm font-bold truncate"
style={{ color: "#1A1A1A" }}
>
{site.name}
</span>
</div>
<div
className="mt-1.5 text-xs truncate"
style={{ color: "#5C5C5C" }}
>
{site.url}
</div>
<div
className="mt-2 text-[11px]"
style={{ color: "#8A8A8A", letterSpacing: "0.01em" }}
>
{site.lastCheck
? `Checked ${formatTimeAgo(site.lastCheck.checked_at)}`
: "No checks yet"}
</div>
</Link>
))}
</div>
)}
</section>
</main>
);
)
}
Loading