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]) +}