From d66c30d7bc9f3994457804ef06f5b3a2259a3079 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 08:17:23 -0800 Subject: [PATCH 1/8] feat: drag to reorder sites for admin users Add position column to sites table and implement drag-and-drop reordering using @dnd-kit. Logged-in admins see a grip handle on hover and can drag list items to set custom order. Order persists via a new reorderSites server action. New sites are appended at the end. Closes #41 Co-Authored-By: Claude Opus 4.6 --- src/app/page.tsx | 105 +------ src/app/sites/actions.ts | 29 +- src/components/SiteList.tsx | 265 ++++++++++++++++++ src/lib/supabase/types.ts | 1 + .../20250222000001_add_site_position.sql | 10 + 5 files changed, 308 insertions(+), 102 deletions(-) create mode 100644 src/components/SiteList.tsx create mode 100644 supabase/migrations/20250222000001_add_site_position.sql diff --git a/src/app/page.tsx b/src/app/page.tsx index 3873630..4077834 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,8 @@ 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" -import AddSiteCard from "@/components/AddSiteCard" -import SiteFormDialog from "@/components/SiteFormDialog" +import { formatDuration } from "@/lib/format" +import SiteList from "@/components/SiteList" import RealtimeStatusPage from "@/components/RealtimeStatusPage" export const revalidate = 0 @@ -17,7 +16,7 @@ async function getStatusData() { const { data: sites } = await supabase .from("sites") .select("*") - .order("name") + .order("position") const { data: openIncidents } = await supabase .from("incidents") @@ -127,103 +126,7 @@ export default async function StatusPage() { Sites - {sites.length === 0 && !isAdmin ? ( -

- No sites are being monitored yet. -

- ) : ( -
- {sites.map((site) => ( -
- -
-
- - - {site.name} - -
- - {site.url} - - {isAdmin && ( -
- - - - - - - } - /> -
- )} - - {site.lastCheck - ? `Checked ${formatTimeAgo(site.lastCheck.checked_at)}` - : "No checks yet"} - -
-
- ))} - {isAdmin && } -
- )} + ) diff --git a/src/app/sites/actions.ts b/src/app/sites/actions.ts index c44080c..8e4bb95 100644 --- a/src/app/sites/actions.ts +++ b/src/app/sites/actions.ts @@ -17,7 +17,16 @@ export async function addSite(formData: FormData) { if (!name || !url) return - await supabase.from("sites").insert({ name, url }) + const { data: maxRow } = await supabase + .from("sites") + .select("position") + .order("position", { ascending: false }) + .limit(1) + .single() + + const position = (maxRow?.position ?? -1) + 1 + + await supabase.from("sites").insert({ name, url, position }) revalidatePath("/") } @@ -50,3 +59,21 @@ export async function deleteSite(siteId: string) { await supabase.from("sites").delete().eq("id", siteId) revalidatePath("/") } + +export async function reorderSites(orderedIds: string[]) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect("/login") + + for (let i = 0; i < orderedIds.length; i++) { + await supabase + .from("sites") + .update({ position: i }) + .eq("id", orderedIds[i]) + } + + revalidatePath("/") +} diff --git a/src/components/SiteList.tsx b/src/components/SiteList.tsx new file mode 100644 index 0000000..bf841d1 --- /dev/null +++ b/src/components/SiteList.tsx @@ -0,0 +1,265 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core" +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import type { Site, Check } from "@/lib/supabase/types" +import { formatTimeAgo } from "@/lib/format" +import { reorderSites } from "@/app/sites/actions" +import SiteFormDialog from "@/components/SiteFormDialog" +import AddSiteCard from "@/components/AddSiteCard" + +type SiteWithLastCheck = Site & { lastCheck: Check | null } + +function SortableSiteCard({ + site, + isAdmin, +}: { + site: SiteWithLastCheck + isAdmin: boolean +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: site.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( +
+ +
+ ) +} + +function SiteCard({ + site, + isAdmin, + dragHandleProps, +}: { + site: SiteWithLastCheck + isAdmin: boolean + dragHandleProps?: Record +}) { + return ( +
+ +
+ {isAdmin && dragHandleProps && ( + + )} +
+ + + {site.name} + +
+ + {site.url} + + {isAdmin && ( +
+ + + + + + + } + /> +
+ )} + + {site.lastCheck + ? `Checked ${formatTimeAgo(site.lastCheck.checked_at)}` + : "No checks yet"} + +
+
+ ) +} + +export default function SiteList({ + sites: initialSites, + isAdmin, +}: { + sites: SiteWithLastCheck[] + isAdmin: boolean +}) { + const [sites, setSites] = useState(initialSites) + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = sites.findIndex((s) => s.id === active.id) + const newIndex = sites.findIndex((s) => s.id === over.id) + const reordered = arrayMove(sites, oldIndex, newIndex) + + setSites(reordered) + reorderSites(reordered.map((s) => s.id)) + } + + if (sites.length === 0 && !isAdmin) { + return ( +

+ No sites are being monitored yet. +

+ ) + } + + if (!isAdmin) { + return ( +
+ {sites.map((site) => ( + + ))} +
+ ) + } + + return ( + + s.id)} + strategy={verticalListSortingStrategy} + > +
+ {sites.map((site) => ( + + ))} + +
+
+
+ ) +} diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index 36625df..8241eb0 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -2,6 +2,7 @@ export type Site = { id: string name: string url: string + position: number created_at: string } diff --git a/supabase/migrations/20250222000001_add_site_position.sql b/supabase/migrations/20250222000001_add_site_position.sql new file mode 100644 index 0000000..41ef7bc --- /dev/null +++ b/supabase/migrations/20250222000001_add_site_position.sql @@ -0,0 +1,10 @@ +-- Add position column for custom ordering +ALTER TABLE sites ADD COLUMN position integer NOT NULL DEFAULT 0; + +-- Backfill existing rows with sequential positions based on alphabetical order +WITH ranked AS ( + SELECT id, ROW_NUMBER() OVER (ORDER BY name) - 1 AS pos + FROM sites +) +UPDATE sites SET position = ranked.pos +FROM ranked WHERE sites.id = ranked.id; From fba0829af01b5a74667574e0dc55d5f8463266c4 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 08:43:48 -0800 Subject: [PATCH 2/8] fix: update sites-crud tests for position-aware addSite and add reorderSites tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The addSite mock now handles the select→order→limit→single chain for fetching max position, and the insert assertion includes the position field. Also adds tests for the new reorderSites action. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/sites-crud.test.ts | 48 ++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/__tests__/sites-crud.test.ts b/src/__tests__/sites-crud.test.ts index 6d61d16..d3e7404 100644 --- a/src/__tests__/sites-crud.test.ts +++ b/src/__tests__/sites-crud.test.ts @@ -26,7 +26,7 @@ vi.mock("@/lib/supabase/server", () => ({ }), })) -import { addSite, editSite, deleteSite } from "@/app/sites/actions" +import { addSite, editSite, deleteSite, reorderSites } from "@/app/sites/actions" describe("addSite action", () => { beforeEach(() => { @@ -49,7 +49,17 @@ describe("addSite action", () => { }) const mockInsert = vi.fn().mockReturnValue({ error: null }) - mockFrom.mockReturnValue({ insert: mockInsert }) + const mockSingle = vi.fn().mockResolvedValue({ data: { position: 2 } }) + const mockLimit = vi.fn().mockReturnValue({ single: mockSingle }) + const mockOrder = vi.fn().mockReturnValue({ limit: mockLimit }) + const mockSelect = vi.fn().mockReturnValue({ order: mockOrder }) + mockFrom.mockImplementation((table: string) => { + // First call: select max position; second call: insert + if (mockFrom.mock.calls.length <= 1) { + return { select: mockSelect } + } + return { insert: mockInsert } + }) const formData = new FormData() formData.set("name", "Test Site") @@ -60,6 +70,7 @@ describe("addSite action", () => { expect(mockInsert).toHaveBeenCalledWith({ name: "Test Site", url: "https://example.com", + position: 3, }) }) @@ -154,3 +165,36 @@ describe("deleteSite action", () => { expect(mockEq).toHaveBeenCalledWith("id", "site-1") }) }) + +describe("reorderSites action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("redirects unauthenticated users to login", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + await expect(reorderSites(["a", "b"])).rejects.toThrow( + "NEXT_REDIRECT:/login" + ) + }) + + test("updates positions for authenticated users", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockEq = vi.fn().mockReturnValue({ error: null }) + const mockUpdate = vi.fn().mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ update: mockUpdate }) + + await reorderSites(["site-b", "site-a", "site-c"]) + expect(mockFrom).toHaveBeenCalledWith("sites") + expect(mockUpdate).toHaveBeenCalledWith({ position: 0 }) + expect(mockUpdate).toHaveBeenCalledWith({ position: 1 }) + expect(mockUpdate).toHaveBeenCalledWith({ position: 2 }) + expect(mockEq).toHaveBeenCalledWith("id", "site-b") + expect(mockEq).toHaveBeenCalledWith("id", "site-a") + expect(mockEq).toHaveBeenCalledWith("id", "site-c") + }) +}) From 22a7c72330cd9961ffaebc7e753aa63a1544cabb Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 08:54:30 -0800 Subject: [PATCH 3/8] fix: add missing @dnd-kit dependencies to package.json The packages were installed locally but not committed when the branch was recreated, causing build failures in CI and Vercel. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 56 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +++ 2 files changed, 59 insertions(+) diff --git a/package-lock.json b/package-lock.json index 13d27c7..fcd62ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "site-status", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.96.0", "next": "^15.2.0", @@ -54,6 +57,59 @@ "commander": "~13.1.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", diff --git a/package.json b/package.json index ab5ee8c..bab47cc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "test:e2e": "env-cmd -f .env.test playwright test" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.96.0", "next": "^15.2.0", From 08639dd51033b460f5f148ed8c3970a4aad93dcd Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 10:22:12 -0800 Subject: [PATCH 4/8] fix: sync SiteList state with server data after revalidation The client component's useState kept stale data after server actions called revalidatePath. Now detects when server-provided props change and syncs local state, so add/edit/delete are reflected immediately. Co-Authored-By: Claude Opus 4.6 --- src/components/SiteList.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/SiteList.tsx b/src/components/SiteList.tsx index bf841d1..c664148 100644 --- a/src/components/SiteList.tsx +++ b/src/components/SiteList.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useRef, useState } from "react" import Link from "next/link" import { DndContext, @@ -200,6 +200,14 @@ export default function SiteList({ }) { const [sites, setSites] = useState(initialSites) + // Sync local state when server data changes (after add/edit/delete revalidation) + const serverKey = initialSites.map((s) => `${s.id}:${s.name}:${s.url}`).join("|") + const prevServerKey = useRef(serverKey) + if (serverKey !== prevServerKey.current) { + prevServerKey.current = serverKey + setSites(initialSites) + } + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 }, From f22276cefd305407100b15326de17607a8f86118 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 10:45:01 -0800 Subject: [PATCH 5/8] design: move drag handle outside card to avoid blank space Position the grip icon to the left of the card border instead of inside the card content area, eliminating the empty gap on the left when the handle is hidden. Co-Authored-By: Claude Opus 4.6 --- src/components/SiteList.tsx | 72 +++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/src/components/SiteList.tsx b/src/components/SiteList.tsx index c664148..f0015df 100644 --- a/src/components/SiteList.tsx +++ b/src/components/SiteList.tsx @@ -53,12 +53,38 @@ function SortableSiteCard({
- + +
) } @@ -66,15 +92,13 @@ function SortableSiteCard({ function SiteCard({ site, isAdmin, - dragHandleProps, }: { site: SiteWithLastCheck isAdmin: boolean - dragHandleProps?: Record }) { return (
- {isAdmin && dragHandleProps && ( - - )}
))} From 43d0111a491c52ab77c9a196160f53696baf9e24 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 10:46:30 -0800 Subject: [PATCH 6/8] chore: add synthetic check data to seed for UAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 4 sites with check histories demonstrating each status: - Healthy Site: all passing → Up (green) - Flaky Site: one 503 among passing checks → Transient Failures (amber) - Broken Site: one HTTP 500 → Failures (red) - Down Site: 6 consecutive 503s with open incident Co-Authored-By: Claude Opus 4.6 --- supabase/seed.sql | 70 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/supabase/seed.sql b/supabase/seed.sql index 4c4d843..a187c5c 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -62,12 +62,74 @@ INSERT INTO auth.identities ( ); -- ============================================================================ --- Sample sites +-- Sample sites (4 sites to demonstrate each status) -- ============================================================================ -INSERT INTO sites (id, name, url) VALUES - ('11111111-1111-1111-1111-111111111111', 'Example Site', 'https://example.com'), - ('22222222-2222-2222-2222-222222222222', 'Anthropic', 'https://www.anthropic.com'); +INSERT INTO sites (id, name, url, position) VALUES + ('11111111-1111-1111-1111-111111111111', 'Healthy Site', 'https://example.com', 0), + ('22222222-2222-2222-2222-222222222222', 'Flaky Site', 'https://flaky.example.com', 1), + ('33333333-3333-3333-3333-333333333333', 'Broken Site', 'https://broken.example.com', 2), + ('44444444-4444-4444-4444-444444444444', 'Down Site (Incident)', 'https://down.example.com', 3); + +-- ============================================================================ +-- Synthetic checks (last hour) to demonstrate all three site statuses +-- ============================================================================ + +-- Site 1: "Healthy Site" — all passing → status: Up (green) +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0001-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0001-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0001-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0001-0000-0000-0000-000000000004', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0001-0000-0000-0000-000000000005', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '30 minutes'), + ('aaaa0001-0000-0000-0000-000000000006', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '25 minutes'), + ('aaaa0001-0000-0000-0000-000000000007', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '20 minutes'), + ('aaaa0001-0000-0000-0000-000000000008', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '15 minutes'), + ('aaaa0001-0000-0000-0000-000000000009', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '10 minutes'), + ('aaaa0001-0000-0000-0000-000000000010', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '5 minutes'); + +-- Site 2: "Flaky Site" — mostly passing with one 503 → status: Transient Failures (amber) +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0002-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0002-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0002-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0002-0000-0000-0000-000000000004', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0002-0000-0000-0000-000000000005', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '30 minutes'), + ('aaaa0002-0000-0000-0000-000000000006', '22222222-2222-2222-2222-222222222222', 'failure', 503, 'HTTP 503', now() - interval '25 minutes'), + ('aaaa0002-0000-0000-0000-000000000007', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '20 minutes'), + ('aaaa0002-0000-0000-0000-000000000008', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '15 minutes'), + ('aaaa0002-0000-0000-0000-000000000009', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '10 minutes'), + ('aaaa0002-0000-0000-0000-000000000010', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '5 minutes'); + +-- Site 3: "Broken Site" — has a hard failure (HTTP 500) → status: Failures (red) +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0003-0000-0000-0000-000000000001', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0003-0000-0000-0000-000000000002', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0003-0000-0000-0000-000000000003', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0003-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0003-0000-0000-0000-000000000005', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '30 minutes'), + ('aaaa0003-0000-0000-0000-000000000006', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '25 minutes'), + ('aaaa0003-0000-0000-0000-000000000007', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '20 minutes'), + ('aaaa0003-0000-0000-0000-000000000008', '33333333-3333-3333-3333-333333333333', 'failure', 500, 'HTTP 500', now() - interval '15 minutes'), + ('aaaa0003-0000-0000-0000-000000000009', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '10 minutes'), + ('aaaa0003-0000-0000-0000-000000000010', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '5 minutes'); + +-- Site 4: "Down Site (Incident)" — 3 soft failures triggered an incident, still failing +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0004-0000-0000-0000-000000000001', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0004-0000-0000-0000-000000000002', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0004-0000-0000-0000-000000000003', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0004-0000-0000-0000-000000000004', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0004-0000-0000-0000-000000000005', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '30 minutes'), + ('aaaa0004-0000-0000-0000-000000000006', '44444444-4444-4444-4444-444444444444', 'failure', 502, 'HTTP 502', now() - interval '25 minutes'), + ('aaaa0004-0000-0000-0000-000000000007', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '20 minutes'), + ('aaaa0004-0000-0000-0000-000000000008', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '15 minutes'), + ('aaaa0004-0000-0000-0000-000000000009', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '10 minutes'), + ('aaaa0004-0000-0000-0000-000000000010', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '5 minutes'); + +-- Open incident for Site 4 (triggered by the 3rd soft failure) +INSERT INTO incidents (id, site_id, check_id, status, opened_at) VALUES + ('bbbb0001-0000-0000-0000-000000000001', '44444444-4444-4444-4444-444444444444', 'aaaa0004-0000-0000-0000-000000000007', 'open', now() - interval '20 minutes'); -- ============================================================================ -- Test contact From 6c848e4bfc0bf6b80b98ad51b3f7c6d7a228db91 Mon Sep 17 00:00:00 2001 From: Jason Crawford Date: Sun, 22 Feb 2026 10:53:49 -0800 Subject: [PATCH 7/8] fix: remove duplicate .group class breaking e2e locators The outer SortableSiteCard wrapper and inner SiteCard both had the Tailwind 'group' class, causing Playwright's .group locator to match two elements per card. Now only the outermost container gets 'group'. Co-Authored-By: Claude Opus 4.6 --- src/components/SiteList.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/SiteList.tsx b/src/components/SiteList.tsx index f0015df..bc3d8f5 100644 --- a/src/components/SiteList.tsx +++ b/src/components/SiteList.tsx @@ -84,7 +84,7 @@ function SortableSiteCard({ - +
) } @@ -92,13 +92,15 @@ function SortableSiteCard({ function SiteCard({ site, isAdmin, + isWrapped, }: { site: SiteWithLastCheck isAdmin: boolean + isWrapped?: boolean }) { return (
Date: Sun, 22 Feb 2026 11:14:13 -0800 Subject: [PATCH 8/8] fix: align cards with header and constrain drag to vertical axis - Position drag handle absolutely in the left margin so cards stay left-aligned with the Sites heading and Add site button - Add restrictToVerticalAxis modifier so items can only be dragged up and down, not freely across the screen Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 15 +++++++++++++++ package.json | 1 + src/components/SiteList.tsx | 9 +++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index fcd62ce..e171945 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@supabase/ssr": "^0.8.0", @@ -84,6 +85,20 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, "node_modules/@dnd-kit/sortable": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", diff --git a/package.json b/package.json index bab47cc..77b6118 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@supabase/ssr": "^0.8.0", diff --git a/src/components/SiteList.tsx b/src/components/SiteList.tsx index bc3d8f5..3445906 100644 --- a/src/components/SiteList.tsx +++ b/src/components/SiteList.tsx @@ -19,6 +19,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" +import { restrictToVerticalAxis } from "@dnd-kit/modifiers" import type { Site, Check } from "@/lib/supabase/types" import { formatTimeAgo } from "@/lib/format" import { reorderSites } from "@/app/sites/actions" @@ -53,13 +54,16 @@ function SortableSiteCard({