diff --git a/src/__tests__/realtime.test.ts b/src/__tests__/realtime.test.ts
new file mode 100644
index 0000000..99987d9
--- /dev/null
+++ b/src/__tests__/realtime.test.ts
@@ -0,0 +1,105 @@
+import { describe, test, expect, vi, beforeEach } from "vitest"
+
+// Mock next/navigation
+const mockRefresh = vi.fn()
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({
+ refresh: mockRefresh,
+ }),
+}))
+
+// Mock Supabase browser client
+const mockOn = vi.fn()
+const mockSubscribe = vi.fn()
+const mockRemoveChannel = vi.fn()
+const mockChannel = {
+ on: mockOn,
+ subscribe: mockSubscribe,
+}
+// Make .on() chainable
+mockOn.mockReturnValue(mockChannel)
+
+vi.mock("@/lib/supabase/client", () => ({
+ createClient: () => ({
+ channel: () => mockChannel,
+ removeChannel: mockRemoveChannel,
+ }),
+}))
+
+// Mock react
+const cleanupFns: (() => void)[] = []
+vi.mock("react", async () => {
+ const actual = await vi.importActual("react")
+ return {
+ ...actual,
+ useEffect: (fn: () => (() => void) | void) => {
+ const cleanup = fn()
+ if (cleanup) cleanupFns.push(cleanup)
+ },
+ useMemo: (fn: () => unknown) => fn(),
+ }
+})
+
+import { useRealtimeSubscription } from "@/hooks/useRealtimeSubscription"
+
+describe("useRealtimeSubscription", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ cleanupFns.length = 0
+ mockOn.mockReturnValue(mockChannel)
+ })
+
+ test("subscribes to specified tables", () => {
+ useRealtimeSubscription([
+ { table: "checks" },
+ { table: "incidents" },
+ ])
+
+ expect(mockOn).toHaveBeenCalledTimes(2)
+ expect(mockOn).toHaveBeenCalledWith(
+ "postgres_changes",
+ expect.objectContaining({ table: "checks", event: "*", schema: "public" }),
+ expect.any(Function)
+ )
+ expect(mockOn).toHaveBeenCalledWith(
+ "postgres_changes",
+ expect.objectContaining({ table: "incidents", event: "*", schema: "public" }),
+ expect.any(Function)
+ )
+ expect(mockSubscribe).toHaveBeenCalledTimes(1)
+ })
+
+ test("subscribes with filter when provided", () => {
+ useRealtimeSubscription([
+ { table: "checks", filter: "site_id=eq.site-1" },
+ ])
+
+ expect(mockOn).toHaveBeenCalledWith(
+ "postgres_changes",
+ expect.objectContaining({
+ table: "checks",
+ filter: "site_id=eq.site-1",
+ }),
+ expect.any(Function)
+ )
+ })
+
+ test("calls router.refresh on change", () => {
+ useRealtimeSubscription([{ table: "checks" }])
+
+ // Get the callback passed to .on()
+ const callback = mockOn.mock.calls[0][2]
+ callback()
+
+ expect(mockRefresh).toHaveBeenCalledTimes(1)
+ })
+
+ test("removes channel on cleanup", () => {
+ useRealtimeSubscription([{ table: "checks" }])
+
+ expect(cleanupFns).toHaveLength(1)
+ cleanupFns[0]()
+
+ expect(mockRemoveChannel).toHaveBeenCalledWith(mockChannel)
+ })
+})
diff --git a/src/app/page.tsx b/src/app/page.tsx
index e6b3aa0..ae293ba 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -4,6 +4,7 @@ import type { Site, Check, Incident } from "@/lib/supabase/types"
import { formatTimeAgo, formatDuration } from "@/lib/format"
import AddSiteCard from "@/components/AddSiteCard"
import EditSiteButton from "@/components/EditSiteButton"
+import RealtimeStatusPage from "@/components/RealtimeStatusPage"
export const revalidate = 0
@@ -58,6 +59,7 @@ export default async function StatusPage() {
className="max-w-[960px] mx-auto"
style={{ padding: "36px 24px 80px" }}
>
+
{incidents.length > 0 && (
[
+ { table: "checks", filter: `site_id=eq.${siteId}` },
+ { table: "incidents", filter: `id=eq.${incidentId}` },
+ ],
+ [siteId, incidentId]
+ )
+ useRealtimeSubscription(subscriptions)
+ return null
+}
diff --git a/src/components/RealtimeSiteDetail.tsx b/src/components/RealtimeSiteDetail.tsx
new file mode 100644
index 0000000..e9f805b
--- /dev/null
+++ b/src/components/RealtimeSiteDetail.tsx
@@ -0,0 +1,16 @@
+"use client"
+
+import { useMemo } from "react"
+import { useRealtimeSubscription } from "@/hooks/useRealtimeSubscription"
+
+export default function RealtimeSiteDetail({ siteId }: { siteId: string }) {
+ const subscriptions = useMemo(
+ () => [
+ { table: "checks", filter: `site_id=eq.${siteId}` },
+ { table: "incidents", filter: `site_id=eq.${siteId}` },
+ ],
+ [siteId]
+ )
+ useRealtimeSubscription(subscriptions)
+ return null
+}
diff --git a/src/components/RealtimeStatusPage.tsx b/src/components/RealtimeStatusPage.tsx
new file mode 100644
index 0000000..b3fa42b
--- /dev/null
+++ b/src/components/RealtimeStatusPage.tsx
@@ -0,0 +1,17 @@
+"use client"
+
+import { useMemo } from "react"
+import { useRealtimeSubscription } from "@/hooks/useRealtimeSubscription"
+
+export default function RealtimeStatusPage() {
+ const subscriptions = useMemo(
+ () => [
+ { table: "checks" },
+ { table: "incidents" },
+ { table: "sites" },
+ ],
+ []
+ )
+ useRealtimeSubscription(subscriptions)
+ return null
+}
diff --git a/src/hooks/useRealtimeSubscription.ts b/src/hooks/useRealtimeSubscription.ts
new file mode 100644
index 0000000..83880d7
--- /dev/null
+++ b/src/hooks/useRealtimeSubscription.ts
@@ -0,0 +1,41 @@
+"use client"
+
+import { useEffect } from "react"
+import { useRouter } from "next/navigation"
+import { createClient } from "@/lib/supabase/client"
+
+type SubscriptionConfig = {
+ table: string
+ filter?: string
+}
+
+export function useRealtimeSubscription(subscriptions: SubscriptionConfig[]) {
+ const router = useRouter()
+
+ useEffect(() => {
+ const supabase = createClient()
+
+ const channel = supabase
+ .channel("realtime-updates")
+
+ for (const sub of subscriptions) {
+ const config: Record = {
+ event: "*",
+ schema: "public",
+ table: sub.table,
+ }
+ if (sub.filter) {
+ config.filter = sub.filter
+ }
+ channel.on("postgres_changes" as never, config, () => {
+ router.refresh()
+ })
+ }
+
+ channel.subscribe()
+
+ return () => {
+ supabase.removeChannel(channel)
+ }
+ }, [router, subscriptions])
+}