civfix
OPERATIONS
@@ -41,13 +29,10 @@ export function OperatorLogin() {
{notAuthorized ? (
<>
Not authorized
-
+
You signed in with Cloudflare Access, but this account is not authorized for the operator
dashboard. Ask an administrator to add your email to the operator allowlist.
-
- This account is not authorized for the operator dashboard.
-
>
) : (
<>
diff --git a/apps/admin/src/features/discovery/directory-query.test.ts b/apps/admin/src/features/discovery/directory-query.test.ts
new file mode 100644
index 0000000..a619460
--- /dev/null
+++ b/apps/admin/src/features/discovery/directory-query.test.ts
@@ -0,0 +1,70 @@
+import { hashKey } from "@tanstack/react-query"
+import { describe, expect, it } from "vitest"
+
+import { EMPTY_VALUE } from "@/lib/empty-value"
+
+import { directoryCounts, toDirectoryQuery } from "./discovery-ui-state"
+
+describe("directory query", () => {
+ it.each([
+ ["attention", "pop", { filter: "needs_mapping", sort: "oldest" }],
+ ["clear", "pop", { filter: "routed", sort: "population" }],
+ ["all", "reports", { filter: "all", sort: "reports" }],
+ ["all", "oldest", { filter: "all", sort: "oldest" }],
+ ] as const)("maps %s sorted by %s", (filter, sort, expected) => {
+ expect(toDirectoryQuery(filter, sort, "all", "")).toEqual(expected)
+ })
+
+ it("adds the layer and search only when set", () => {
+ expect(toDirectoryQuery("all", "pop", "county", "06")).toEqual({
+ filter: "all",
+ sort: "population",
+ layer: "county",
+ q: "06",
+ })
+ expect(hashKey([toDirectoryQuery("all", "pop", "all", "")])).toBe(
+ hashKey([{ filter: "all", sort: "population" }]),
+ )
+ })
+})
+
+describe("directory counts", () => {
+ const list = { isLoading: false, isError: false }
+
+ it("splits the facets across the chips and heads the list with the active filter's count", () => {
+ const counts = directoryCounts({
+ filter: "clear",
+ total: 12,
+ facets: { routed: 5, unrouted: 7 },
+ needsMapping: 3,
+ list,
+ loadedCount: 2,
+ })
+ expect(counts).toEqual({ chips: { attention: 3, clear: 5, all: 12 }, header: 5 })
+ })
+
+ it("heads Needs mapping with its own count query", () => {
+ const counts = directoryCounts({
+ filter: "attention",
+ total: 40,
+ facets: null,
+ needsMapping: "Loading…",
+ list,
+ loadedCount: 0,
+ })
+ expect(counts.header).toBe("Loading…")
+ expect(counts.chips.all).toBe(EMPTY_VALUE)
+ })
+
+ it("falls back to the loaded rows when the server sent no total", () => {
+ const counts = directoryCounts({
+ filter: "all",
+ total: null,
+ facets: null,
+ needsMapping: 0,
+ list,
+ loadedCount: 4,
+ })
+ expect(counts.header).toBe(4)
+ })
+})
diff --git a/apps/admin/src/features/discovery/discovery-page.test.tsx b/apps/admin/src/features/discovery/discovery-page.test.tsx
new file mode 100644
index 0000000..f94d941
--- /dev/null
+++ b/apps/admin/src/features/discovery/discovery-page.test.tsx
@@ -0,0 +1,687 @@
+import { act, screen, waitFor, within } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { QueryClientProvider } from "@tanstack/react-query"
+import { describe, expect, it, onTestFinished, vi } from "vitest"
+import type {
+ GetForwardTemplateDefaultResponse,
+ JurisdictionDirectoryDTO,
+ JurisdictionDirectoryResponse,
+ JurisdictionGeometryResponse,
+} from "@civfix/shared"
+
+import type * as ApiModule from "@/lib/api"
+import { categoryLabel } from "@/lib/category"
+import { EMPTY_VALUE } from "@/lib/empty-value"
+import { makeQueryClient } from "@/lib/query"
+import { useUiStore, type ToastTone } from "@/store/ui-store"
+import { apiMock } from "@/test/api-mock"
+import { renderWithQuery } from "@/test/render"
+import { DiscoveryPage } from "@/features/discovery/discovery-page"
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const { apiMock } = await import("@/test/api-mock")
+ return { ...(await importOriginal
()), api: apiMock }
+})
+
+// Leaflet cannot draw in jsdom; the boundary map is irrelevant to the list/detail states pinned here.
+vi.mock("@/components/map/boundary-map", () => ({
+ BoundaryMap: () => ,
+}))
+
+const DAY_MS = 24 * 60 * 60 * 1000
+
+function jurisdiction(overrides: Partial = {}): JurisdictionDirectoryDTO {
+ return {
+ geoid: "0644000",
+ org: "Los Angeles",
+ dept: null,
+ email: null,
+ form: null,
+ method: "none",
+ status: "pending",
+ coverage: "City limits",
+ lastRouted: null,
+ layer: "place",
+ population: 3_898_747,
+ reportsWaiting: 4,
+ perCategoryCounts: { trash: 3, graffiti: 1 },
+ contacts: [],
+ flaggedAt: null,
+ handle: null,
+ oldestReportAt: new Date(Date.now() - 3 * DAY_MS).toISOString(),
+ forwardSubjectTemplate: null,
+ forwardBodyTemplate: null,
+ ...overrides,
+ } satisfies JurisdictionDirectoryDTO
+}
+
+const LA = jurisdiction()
+const PASADENA = jurisdiction({
+ geoid: "0656000",
+ org: "Pasadena",
+ method: "email",
+ status: "verified",
+ email: "reports@pasadena.gov",
+ population: 138_699,
+ reportsWaiting: 0,
+ perCategoryCounts: {},
+ contacts: [{ category: "trash", email: "trash@pasadena.gov" }],
+ oldestReportAt: null,
+ lastRouted: "2026-09-01T12:00:00.000Z",
+})
+
+function page(
+ items: JurisdictionDirectoryDTO[],
+ extra: Partial = {},
+): JurisdictionDirectoryResponse {
+ return {
+ items,
+ nextCursor: null,
+ total: items.length,
+ facets: { routed: 12, unrouted: items.length },
+ ...extra,
+ } satisfies JurisdictionDirectoryResponse
+}
+
+const GEOMETRY = {
+ geoid: "0644000",
+ layer: "place",
+ geometry: { type: "Polygon", coordinates: [] },
+ bbox: [-118.7, 33.7, -118.1, 34.3],
+ point: { lat: 34.05, lng: -118.25 },
+} as unknown as JurisdictionGeometryResponse
+
+function mockDetailQueries(): void {
+ apiMock.getJurisdictionGeometry.mockResolvedValue(GEOMETRY)
+ apiMock.getForwardTemplateDefault.mockResolvedValue({
+ subjectTemplate: null,
+ bodyTemplate: null,
+ updatedAt: null,
+ } satisfies GetForwardTemplateDefaultResponse)
+}
+
+function listCalls(): Record[] {
+ return apiMock.listJurisdictions.mock.calls.map((call) => call[0] as Record)
+}
+
+function chip(name: RegExp): HTMLElement {
+ return screen.getByRole("button", { name })
+}
+
+function spyToasts() {
+ const original = useUiStore.getState().showToast
+ const showToast = vi.fn<(text: string, tone?: ToastTone) => void>()
+ useUiStore.setState({ showToast })
+ onTestFinished(() => useUiStore.setState({ showToast: original }))
+ return showToast
+}
+
+describe("DiscoveryPage", () => {
+ it("shows the loading state while the directory is in flight", async () => {
+ apiMock.listJurisdictions.mockReturnValue(new Promise(() => {}))
+ renderWithQuery()
+
+ expect(await screen.findByRole("status")).toHaveTextContent("Loading jurisdictions…")
+ expect(chip(/^Needs mapping/)).toHaveTextContent("Loading…")
+ expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent("Loading… jurisdictions")
+ expect(screen.getByText("No jurisdiction selected")).toBeInTheDocument()
+ })
+
+ it("requests the needs-mapping view oldest-first by default, with the sort locked", async () => {
+ apiMock.listJurisdictions.mockReturnValue(new Promise(() => {}))
+ renderWithQuery()
+
+ await waitFor(() => expect(apiMock.listJurisdictions).toHaveBeenCalled())
+ // The list and the needs-mapping count share one query key, so one request serves both.
+ expect(listCalls()).toEqual([{ filter: "needs_mapping", sort: "oldest", limit: 50 }])
+ expect(screen.getByRole("combobox", { name: "Sort jurisdictions" })).toBeDisabled()
+ expect(screen.getByRole("combobox", { name: "Sort jurisdictions" })).toHaveValue("oldest")
+ })
+
+ it("shows the empty state when nothing matches", async () => {
+ apiMock.listJurisdictions.mockResolvedValue(page([], { total: 0, facets: { routed: 0, unrouted: 0 } }))
+ renderWithQuery()
+
+ expect(await screen.findByText("Nothing matches")).toBeInTheDocument()
+ expect(screen.getByText("Try a different filter or search.")).toBeInTheDocument()
+ expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent("0 jurisdictions")
+ expect(screen.getByText("No jurisdiction selected")).toBeInTheDocument()
+ expect(screen.getByText("Pick a place from the list.")).toBeInTheDocument()
+ })
+
+ it("shows the error message with a retry that refetches", async () => {
+ apiMock.listJurisdictions.mockRejectedValue(new Error("directory is down"))
+ renderWithQuery()
+
+ const alert = await screen.findByRole("alert")
+ expect(alert).toHaveTextContent("Could not load this")
+ expect(alert).toHaveTextContent("directory is down")
+ expect(chip(/^Needs mapping/)).toHaveTextContent(new RegExp(`^Needs mapping\\s*${EMPTY_VALUE}$`))
+
+ apiMock.listJurisdictions.mockResolvedValue(page([LA]))
+ await userEvent.click(within(alert).getByRole("button", { name: "Try again" }))
+ expect(await screen.findByRole("heading", { level: 2, name: "Los Angeles" })).toBeInTheDocument()
+ })
+
+ it("renders rows and auto-selects the first one into the detail pane", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA, PASADENA], { total: 2 }))
+ renderWithQuery()
+
+ const laRow = await screen.findByRole("button", { name: /^Los Angeles/ })
+ expect(laRow).toHaveTextContent("City")
+ expect(laRow).toHaveTextContent("Needs contact")
+ expect(laRow).toHaveTextContent("0644000")
+ expect(laRow).toHaveTextContent("3.9M pop")
+ expect(laRow).toHaveTextContent("4 waiting")
+ expect(laRow).toHaveTextContent("City limits")
+ expect(laRow).toHaveTextContent("3d")
+
+ const pasadenaRow = screen.getByRole("button", { name: /^Pasadena/ })
+ expect(pasadenaRow).toHaveTextContent("Routed")
+ expect(pasadenaRow).toHaveTextContent("138.7K pop")
+
+ expect(chip(/^Needs mapping/)).toHaveTextContent("2")
+ expect(chip(/^Routed/)).toHaveTextContent("12")
+ expect(chip(/^All/)).toHaveTextContent("14")
+ expect(screen.getByRole("heading", { level: 3 })).toHaveTextContent("2 jurisdictions")
+
+ expect(screen.getByRole("heading", { level: 2, name: "Los Angeles" })).toBeInTheDocument()
+ expect(screen.getByText("City · Jurisdiction · GEOID 0644000")).toBeInTheDocument()
+ expect(screen.getByText("3,898,747")).toBeInTheDocument()
+ expect(screen.getByText("US Census ACS")).toBeInTheDocument()
+ expect(screen.getByText("2 types with no contact")).toBeInTheDocument()
+ expect(screen.getByText("0 of 7 contacts set")).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: /Save & route/ })).toBeDisabled()
+ expect(await screen.findByTestId("boundary-map")).toBeInTheDocument()
+ expect(apiMock.getJurisdictionGeometry).toHaveBeenCalledWith({ geoid: "0644000" })
+ })
+
+ it("opens the clicked row in the detail pane", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA, PASADENA], { total: 2 }))
+ renderWithQuery()
+
+ await userEvent.click(await screen.findByRole("button", { name: /^Pasadena/ }))
+
+ expect(screen.getByRole("heading", { level: 2, name: "Pasadena" })).toBeInTheDocument()
+ expect(screen.getByText("City · Jurisdiction · GEOID 0656000")).toBeInTheDocument()
+ expect(screen.getByDisplayValue("reports@pasadena.gov")).toBeInTheDocument()
+ expect(screen.getByDisplayValue("trash@pasadena.gov")).toBeInTheDocument()
+ expect(screen.getByText("1 of 7 contacts set")).toBeInTheDocument()
+ expect(screen.getByText("all routed")).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: /Save & route/ })).toBeEnabled()
+ await waitFor(() =>
+ expect(apiMock.getJurisdictionGeometry).toHaveBeenCalledWith({ geoid: "0656000" }),
+ )
+ })
+
+ it("renders the unmapped bucket as a triage row and detail", async () => {
+ apiMock.listJurisdictions.mockResolvedValue(
+ page([
+ jurisdiction({
+ geoid: "__unmapped__",
+ org: "Unmapped reports",
+ reportsWaiting: 5,
+ perCategoryCounts: { hazard: 5 },
+ }),
+ ]),
+ )
+ renderWithQuery()
+
+ const row = await screen.findByRole("button", { name: /^Unmapped reports/ })
+ expect(row).toHaveTextContent("Needs mapping")
+ expect(row).toHaveTextContent("5 waiting")
+ expect(screen.getByText("Triage · location did not resolve")).toBeInTheDocument()
+ expect(screen.getByRole("heading", { level: 2, name: "Unmapped reports" })).toBeInTheDocument()
+ expect(screen.getByText("Waiting reports by type")).toBeInTheDocument()
+ expect(screen.getByText("5 reports")).toBeInTheDocument()
+ expect(apiMock.getJurisdictionGeometry).not.toHaveBeenCalled()
+ })
+
+ it("loads the next page with the cursor when Load more is clicked", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions
+ .mockResolvedValueOnce(page([LA], { nextCursor: "cursor-2", total: 2 }))
+ .mockResolvedValueOnce(page([PASADENA], { nextCursor: null }))
+ renderWithQuery()
+
+ await userEvent.click(await screen.findByRole("button", { name: "Load more" }))
+
+ expect(await screen.findByRole("button", { name: /^Pasadena/ })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: /^Los Angeles/ })).toBeInTheDocument()
+ expect(listCalls()).toEqual([
+ { filter: "needs_mapping", sort: "oldest", limit: 50 },
+ { filter: "needs_mapping", sort: "oldest", limit: 50, cursor: "cursor-2" },
+ ])
+ expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument()
+ })
+
+ it("maps the filter chips, type and sort controls onto the request params", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA]))
+ renderWithQuery()
+ await screen.findByRole("button", { name: /^Los Angeles/ })
+
+ await userEvent.click(chip(/^Routed/))
+ await waitFor(() =>
+ expect(listCalls()).toContainEqual({ filter: "routed", sort: "population", limit: 50 }),
+ )
+ const sort = screen.getByRole("combobox", { name: "Sort jurisdictions" })
+ expect(sort).toBeEnabled()
+ expect(sort).toHaveValue("pop")
+
+ await userEvent.selectOptions(sort, "reports")
+ await waitFor(() =>
+ expect(listCalls()).toContainEqual({ filter: "routed", sort: "reports", limit: 50 }),
+ )
+
+ await userEvent.click(chip(/^All/))
+ await waitFor(() =>
+ expect(listCalls()).toContainEqual({ filter: "all", sort: "reports", limit: 50 }),
+ )
+
+ await userEvent.selectOptions(
+ screen.getByRole("combobox", { name: "Filter by jurisdiction type" }),
+ "county",
+ )
+ await waitFor(() =>
+ expect(listCalls()).toContainEqual({ filter: "all", sort: "reports", layer: "county", limit: 50 }),
+ )
+ await waitFor(() =>
+ expect(listCalls()).toContainEqual({
+ filter: "needs_mapping",
+ sort: "oldest",
+ layer: "county",
+ limit: 50,
+ }),
+ )
+
+ await userEvent.click(chip(/^Needs mapping/))
+ expect(screen.getByRole("combobox", { name: "Sort jurisdictions" })).toBeDisabled()
+ expect(screen.getByRole("combobox", { name: "Sort jurisdictions" })).toHaveValue("oldest")
+ })
+
+ it("sends the trimmed search after the debounce", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA]))
+ renderWithQuery()
+ await screen.findByRole("button", { name: /^Los Angeles/ })
+
+ await userEvent.type(screen.getByPlaceholderText("Search place or GEOID…"), " pasa ")
+
+ await waitFor(() =>
+ expect(listCalls()).toContainEqual({ filter: "needs_mapping", sort: "oldest", q: "pasa", limit: 50 }),
+ )
+ })
+
+ it("deep-links a focusId on the All filter with the search already applied, so a routed jurisdiction opens", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockImplementation(async (params: { filter?: string; q?: string }) =>
+ params.filter === "all" && params.q === "0656000" ? page([PASADENA]) : page([LA]),
+ )
+ renderWithQuery()
+
+ expect(screen.getByPlaceholderText("Search place or GEOID…")).toHaveValue("0656000")
+ expect(await screen.findByRole("heading", { level: 2, name: "Pasadena" })).toBeInTheDocument()
+ expect(listCalls()[0]).toEqual({ filter: "all", sort: "population", q: "0656000", limit: 50 })
+ expect(listCalls().every((call) => call.q === "0656000")).toBe(true)
+ })
+
+ it("switches to the All filter when a new focusId arrives", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockImplementation(async (params: { filter?: string; q?: string }) =>
+ params.filter === "all" && params.q === "0656000" ? page([PASADENA]) : page([LA]),
+ )
+ const { rerender, client } = renderWithQuery()
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+
+ rerender(
+
+
+ ,
+ )
+
+ expect(await screen.findByRole("heading", { level: 2, name: "Pasadena" })).toBeInTheDocument()
+ expect(screen.getByPlaceholderText("Search place or GEOID…")).toHaveValue("0656000")
+ })
+
+ it("keeps a focused jurisdiction selected when the list does not have it, and says so", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA]))
+ renderWithQuery()
+
+ const laRow = await screen.findByRole("button", { name: /^Los Angeles/ })
+ expect(screen.getByText("Not in this list")).toBeInTheDocument()
+ expect(screen.getByText("No loaded jurisdiction has GEOID 9999999.")).toBeInTheDocument()
+ expect(screen.queryByRole("heading", { level: 2 })).not.toBeInTheDocument()
+ expect(laRow).not.toHaveAttribute("aria-current")
+ })
+
+ it("clears a jurisdiction that saving drops from the Needs mapping list, without opening another", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA, PASADENA]))
+ apiMock.saveJurisdictionContacts.mockImplementation(async () => {
+ apiMock.listJurisdictions.mockResolvedValue(page([PASADENA]))
+ return { ok: true }
+ })
+ renderWithQuery()
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+
+ await userEvent.type(screen.getByRole("textbox", { name: "Default contact email" }), "reports@la.gov")
+ await userEvent.click(screen.getByRole("button", { name: /Save & route/ }))
+
+ await waitFor(() =>
+ expect(screen.queryByRole("button", { name: /^Los Angeles/ })).not.toBeInTheDocument(),
+ )
+ expect(await screen.findByText("No jurisdiction selected")).toBeInTheDocument()
+ expect(screen.queryByRole("heading", { level: 2 })).not.toBeInTheDocument()
+ expect(screen.getByRole("button", { name: /^Pasadena/ })).not.toHaveAttribute("aria-current")
+ })
+
+ it("confirms Save & route even when the list drops the jurisdiction before the needs-mapping count refetch lands", async () => {
+ useUiStore.setState({ toast: null })
+ mockDetailQueries()
+ let releaseCount: () => void = () => {}
+ apiMock.listJurisdictions.mockResolvedValue(page([LA, PASADENA]))
+ apiMock.saveJurisdictionContacts.mockImplementation(async () => {
+ apiMock.listJurisdictions.mockImplementation((params: { filter?: string }) =>
+ params.filter === "needs_mapping"
+ ? new Promise((resolve) => {
+ releaseCount = () => resolve(page([PASADENA]))
+ })
+ : Promise.resolve(page([PASADENA])),
+ )
+ return { ok: true }
+ })
+ renderWithQuery()
+ await userEvent.click(chip(/^All/))
+ await waitFor(() => expect(listCalls().at(-1)).toMatchObject({ filter: "all" }))
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+
+ await userEvent.type(screen.getByRole("textbox", { name: "Default contact email" }), "reports@la.gov")
+ await userEvent.click(screen.getByRole("button", { name: /Save & route/ }))
+
+ expect(await screen.findByText("No jurisdiction selected")).toBeInTheDocument()
+ await act(async () => {
+ releaseCount()
+ })
+ await waitFor(() =>
+ expect(useUiStore.getState().toast).toMatchObject({
+ text: "Contacts saved for Los Angeles · discovery task closed",
+ tone: "ok",
+ }),
+ )
+ })
+
+ it("marks the selected row current", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA, PASADENA]))
+ renderWithQuery()
+
+ const laRow = await screen.findByRole("button", { name: /^Los Angeles/ })
+ expect(laRow).toHaveAttribute("aria-current", "true")
+ expect(screen.getByRole("button", { name: /^Pasadena/ })).not.toHaveAttribute("aria-current")
+ })
+
+ it("shows loading, not zero, on the Routed and All chips while the directory loads", async () => {
+ apiMock.listJurisdictions.mockReturnValue(new Promise(() => {}))
+ renderWithQuery()
+
+ await screen.findByRole("status")
+ expect(chip(/^Routed/)).toHaveTextContent("Loading…")
+ expect(chip(/^All/)).toHaveTextContent("Loading…")
+ })
+
+ it("keeps the operator's sort through a visit to Needs mapping", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA]))
+ renderWithQuery()
+ await screen.findByRole("button", { name: /^Los Angeles/ })
+
+ await userEvent.click(chip(/^Routed/))
+ await userEvent.selectOptions(screen.getByRole("combobox", { name: "Sort jurisdictions" }), "reports")
+ await userEvent.click(chip(/^Needs mapping/))
+ await userEvent.click(chip(/^Routed/))
+
+ expect(screen.getByRole("combobox", { name: "Sort jurisdictions" })).toHaveValue("reports")
+ })
+
+ it("names every search and contact field", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA]))
+ renderWithQuery()
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+
+ for (const name of [
+ "Search jurisdictions",
+ "Note for the next operator",
+ "Default contact email",
+ "Report form URL",
+ `${categoryLabel("trash")} contact email`,
+ ]) {
+ expect(screen.getByRole("textbox", { name })).toBeInTheDocument()
+ }
+ })
+})
+
+describe("DiscoveryPage saving", () => {
+ it("sends null for a contact the operator emptied, so the old address stops receiving reports", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([PASADENA]))
+ apiMock.patchJurisdiction.mockResolvedValue({ ok: true })
+ renderWithQuery()
+ await screen.findByRole("heading", { level: 2, name: "Pasadena" })
+
+ await userEvent.clear(screen.getByDisplayValue("trash@pasadena.gov"))
+ await userEvent.click(screen.getByRole("button", { name: "Save draft" }))
+
+ await waitFor(() => expect(apiMock.patchJurisdiction).toHaveBeenCalled())
+ expect(apiMock.patchJurisdiction).toHaveBeenCalledWith({
+ geoid: "0656000",
+ contacts: { trash: null },
+ defaultEmails: ["reports@pasadena.gov"],
+ })
+ expect(
+ screen.getByText(/Clearing the default email or form URL here does not remove the saved one\./),
+ ).toBeInTheDocument()
+ })
+
+ it("sends null for a contact saved earlier in this visit and then emptied", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA]))
+ apiMock.patchJurisdiction.mockResolvedValue({ ok: true })
+ renderWithQuery()
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+ const trash = screen.getByRole("textbox", { name: `${categoryLabel("trash")} contact email` })
+
+ await userEvent.type(trash, "wrong@la.gov")
+ await userEvent.click(screen.getByRole("button", { name: "Save draft" }))
+ await waitFor(() =>
+ expect(apiMock.patchJurisdiction).toHaveBeenLastCalledWith({
+ geoid: "0644000",
+ contacts: { trash: "wrong@la.gov" },
+ }),
+ )
+ await waitFor(() => expect(screen.getByRole("button", { name: "Save draft" })).toBeEnabled())
+
+ await userEvent.clear(trash)
+ await userEvent.click(screen.getByRole("button", { name: "Save draft" }))
+
+ await waitFor(() => expect(apiMock.patchJurisdiction).toHaveBeenCalledTimes(2))
+ expect(apiMock.patchJurisdiction).toHaveBeenLastCalledWith({
+ geoid: "0644000",
+ contacts: { trash: null },
+ })
+ })
+
+ it("routes with every contact the form shows, including ones saved before this visit", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(
+ page([jurisdiction({ contacts: [{ category: "trash", email: "trash@la.gov" }] })]),
+ )
+ apiMock.saveJurisdictionContacts.mockResolvedValue({ ok: true })
+ renderWithQuery()
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+
+ await userEvent.click(screen.getByRole("button", { name: /Save & route/ }))
+
+ await waitFor(() =>
+ expect(apiMock.saveJurisdictionContacts).toHaveBeenCalledWith({
+ geoid: "0644000",
+ contacts: { trash: "trash@la.gov" },
+ }),
+ )
+ })
+
+ it("routes with the shown contacts plus null for one the operator emptied", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(
+ page([
+ jurisdiction({
+ contacts: [
+ { category: "trash", email: "trash@la.gov" },
+ { category: "graffiti", email: "old@la.gov" },
+ ],
+ }),
+ ]),
+ )
+ apiMock.saveJurisdictionContacts.mockResolvedValue({ ok: true })
+ renderWithQuery()
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+
+ await userEvent.clear(screen.getByDisplayValue("old@la.gov"))
+ await userEvent.click(screen.getByRole("button", { name: /Save & route/ }))
+
+ await waitFor(() =>
+ expect(apiMock.saveJurisdictionContacts).toHaveBeenCalledWith({
+ geoid: "0644000",
+ contacts: { trash: "trash@la.gov", graffiti: null },
+ }),
+ )
+ })
+
+ it("normalizes the @handle the way the server does before comparing it", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([jurisdiction({ handle: "la" })]))
+ renderWithQuery()
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+
+ const handle = screen.getByRole("textbox", { name: "Jurisdiction discussion handle" })
+ await userEvent.clear(handle)
+ await userEvent.type(handle, "@ LA")
+ expect(screen.getByText(/Residents can tag \u201c@la\u201d/)).toBeInTheDocument()
+
+ await userEvent.click(screen.getByRole("button", { name: "Save draft" }))
+ expect(apiMock.patchJurisdiction).not.toHaveBeenCalled()
+ })
+
+ it("shows an invalid @handle inline and holds both saves", async () => {
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([PASADENA]))
+ renderWithQuery()
+ await screen.findByRole("heading", { level: 2, name: "Pasadena" })
+
+ await userEvent.type(screen.getByRole("textbox", { name: "Jurisdiction discussion handle" }), "a")
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Handle must be 2-40 characters using lowercase letters, numbers, or underscores.",
+ )
+ expect(screen.getByRole("button", { name: "Save draft" })).toBeDisabled()
+ expect(screen.getByRole("button", { name: /Save & route/ })).toBeDisabled()
+ })
+
+ it("shows one error toast when Save draft fails", async () => {
+ const toasts = spyToasts()
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA]))
+ apiMock.patchJurisdiction.mockRejectedValue(new Error("Jurisdiction service down"))
+ renderWithQuery(, makeQueryClient())
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+
+ await userEvent.type(screen.getByRole("textbox", { name: "Default contact email" }), "reports@la.gov")
+ await userEvent.click(screen.getByRole("button", { name: "Save draft" }))
+
+ await waitFor(() => expect(toasts).toHaveBeenCalled())
+ expect(toasts).toHaveBeenCalledTimes(1)
+ expect(toasts).toHaveBeenCalledWith("Jurisdiction service down", "error")
+ })
+
+ it("shows one error toast when saving the email template fails", async () => {
+ const toasts = spyToasts()
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA]))
+ apiMock.patchJurisdiction.mockRejectedValue(new Error("Jurisdiction service down"))
+ renderWithQuery(, makeQueryClient())
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+ const edit = screen.getByRole("button", { name: "Edit template" })
+ await waitFor(() => expect(edit).toBeEnabled())
+
+ await userEvent.click(edit)
+ const dialog = screen.getByRole("dialog", { name: "Forwarding email for Los Angeles" })
+ await userEvent.type(within(dialog).getByLabelText("Subject"), "Report forwarded")
+ await userEvent.click(within(dialog).getByRole("button", { name: /Save/ }))
+
+ await waitFor(() => expect(toasts).toHaveBeenCalled())
+ expect(toasts).toHaveBeenCalledTimes(1)
+ })
+
+ it("says what was saved when Save & route saves the note but not the contacts", async () => {
+ const toasts = spyToasts()
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA]))
+ apiMock.patchJurisdiction.mockResolvedValue({ ok: true })
+ apiMock.saveJurisdictionContacts.mockRejectedValue(new Error("Contacts service down"))
+ renderWithQuery(, makeQueryClient())
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+
+ await userEvent.type(
+ screen.getByRole("textbox", { name: "Note for the next operator" }),
+ "Called the clerk",
+ )
+ await userEvent.type(screen.getByRole("textbox", { name: "Default contact email" }), "reports@la.gov")
+ await userEvent.click(screen.getByRole("button", { name: /Save & route/ }))
+
+ await waitFor(() => expect(toasts).toHaveBeenCalled())
+ expect(apiMock.patchJurisdiction).toHaveBeenCalledWith({ geoid: "0644000", notes: "Called the clerk" })
+ expect(toasts).toHaveBeenCalledTimes(1)
+ expect(toasts).toHaveBeenCalledWith(
+ "The note was saved, but the contacts were not: Contacts service down",
+ "error",
+ )
+ })
+
+ it("still says what was saved when Save & route fails after the operator moved to another jurisdiction", async () => {
+ const toasts = spyToasts()
+ mockDetailQueries()
+ apiMock.listJurisdictions.mockResolvedValue(page([LA, PASADENA]))
+ apiMock.patchJurisdiction.mockResolvedValue({ ok: true })
+ let rejectSave: (error: Error) => void = () => {}
+ apiMock.saveJurisdictionContacts.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectSave = reject
+ }),
+ )
+ renderWithQuery(, makeQueryClient())
+ await screen.findByRole("heading", { level: 2, name: "Los Angeles" })
+
+ await userEvent.type(
+ screen.getByRole("textbox", { name: "Note for the next operator" }),
+ "Called the clerk",
+ )
+ await userEvent.type(screen.getByRole("textbox", { name: "Default contact email" }), "reports@la.gov")
+ await userEvent.click(screen.getByRole("button", { name: /Save & route/ }))
+ await waitFor(() => expect(apiMock.saveJurisdictionContacts).toHaveBeenCalled())
+
+ await userEvent.click(screen.getByRole("button", { name: /^Pasadena/ }))
+ await screen.findByRole("heading", { level: 2, name: "Pasadena" })
+ rejectSave(new Error("Contacts service down"))
+
+ await waitFor(() => expect(toasts).toHaveBeenCalled())
+ expect(toasts).toHaveBeenCalledTimes(1)
+ expect(toasts).toHaveBeenCalledWith(
+ "The note was saved, but the contacts were not: Contacts service down",
+ "error",
+ )
+ })
+})
diff --git a/apps/admin/src/features/discovery/discovery-page.tsx b/apps/admin/src/features/discovery/discovery-page.tsx
index 3f6567b..f2afe97 100644
--- a/apps/admin/src/features/discovery/discovery-page.tsx
+++ b/apps/admin/src/features/discovery/discovery-page.tsx
@@ -1,842 +1,235 @@
"use client"
import * as React from "react"
-import dynamic from "next/dynamic"
-import {
- DEFAULT_FORWARD_BODY_TEMPLATE,
- DEFAULT_FORWARD_SUBJECT_TEMPLATE,
- type DiscoveryContact,
- type JurisdictionDirectoryDTO,
- type JurisdictionLayer,
- type PerCategoryCounts,
- type ReportCategory,
-} from "@civfix/shared"
+import type { JurisdictionDirectoryDTO } from "@civfix/shared"
import { Icons } from "@/components/icons"
-import { toAppError } from "@/lib/api"
-import {
- REPORT_CATEGORIES,
- categoryLabel,
- categoryPinSrc,
- categoryReportTypes,
-} from "@/lib/category"
-import { promptDialog } from "@/components/shared/dialog"
import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives"
-import { LoadingState, ErrorState } from "@/components/shared/data-states"
-import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation"
+import { ListCard, ListStates, LoadMoreButton, SearchBox } from "@/components/shared/section-list"
+import { useNow } from "@/hooks/use-now"
+import { useSelection } from "@/hooks/use-selection"
+import { useJurisdictionListInfinite } from "@/features/discovery/use-discovery"
import {
- useJurisdictionDirectory,
- useJurisdictionGeometry,
- usePatchJurisdiction,
- useSaveJurisdictionContacts,
-} from "@/features/discovery/use-discovery"
-import {
- getJurisdictionSort,
- getNeedsMappingCountDisplay,
+ directoryCounts,
+ countDisplay,
+ effectiveJurisdictionSort,
+ initialDirectoryState,
+ toDirectoryQuery,
+ type CountDisplay,
type JurisdictionFilter,
type JurisdictionSort,
} from "@/features/discovery/discovery-ui-state"
-import { ForwardTemplateModal } from "@/features/mail/forward-template-modal"
-import { useForwardTemplateDefault } from "@/features/mail/use-mail"
-
-const BoundaryMap = dynamic(
- () => import("@/components/map/boundary-map").then((m) => m.BoundaryMap),
- { ssr: false },
-)
-import { useToast } from "@/store/ui-store"
+import { JurisdictionDetail } from "@/features/discovery/jurisdiction-detail"
+import { JurisdictionRow } from "@/features/discovery/jurisdiction-row"
+import {
+ LAYER_OPTIONS,
+ UNMAPPED_GEOID,
+ type LayerChoice,
+} from "@/features/discovery/jurisdiction-view"
+import { UnmappedDetail } from "@/features/discovery/unmapped-detail"
+import { SEARCH_DEBOUNCE_MS } from "@/lib/timing"
+import { flatPages } from "@/lib/infinite"
import type { SectionPageProps } from "@/components/shell/page-registry"
+type DirectoryList = ReturnType
-const LAYER_LABEL: Record = {
- place: "City",
- county: "County",
- state: "State",
- federal: "Federal land",
- tribal: "Tribal",
-}
-
-const UNMAPPED_GEOID = "__unmapped__"
-
-function fmtRouted(iso: string | null): string {
- if (!iso) return "—"
- const d = new Date(iso)
- if (Number.isNaN(d.getTime())) return "—"
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
-}
-
-const HOUR_MS = 60 * 60 * 1000
-
-/** Compact relative age ("just now", "5h", "3d", "2w", "4mo", "1y") for the oldest waiting report. */
-function fmtAge(iso: string | null): string {
- if (!iso) return "—"
- const then = new Date(iso).getTime()
- if (Number.isNaN(then)) return "—"
- const diff = Date.now() - then
- if (diff < 60 * 1000) return "just now"
- const mins = Math.floor(diff / (60 * 1000))
- if (mins < 60) return `${mins}m`
- const hours = Math.floor(diff / HOUR_MS)
- if (hours < 24) return `${hours}h`
- const days = Math.floor(diff / (24 * HOUR_MS))
- if (days < 14) return `${days}d`
- const weeks = Math.floor(days / 7)
- if (weeks < 8) return `${weeks}w`
- const months = Math.floor(days / 30)
- if (months < 12) return `${months}mo`
- return `${Math.floor(days / 365)}y`
-}
-
-/** Overdue once the oldest waiting report is older than ~24h. */
-function isOverdue(iso: string | null): boolean {
- if (!iso) return false
- const then = new Date(iso).getTime()
- if (Number.isNaN(then)) return false
- return Date.now() - then > 24 * HOUR_MS
-}
-
-const REPORT_TYPES: { id: ReportCategory; label: string; pin: string; types: string }[] =
- REPORT_CATEGORIES.map((id) => ({
- id,
- label: categoryLabel(id),
- pin: categoryPinSrc(id),
- types: categoryReportTypes(id),
- }))
-
-function routingCount(counts: PerCategoryCounts, id: ReportCategory): number {
- return counts[id] ?? 0
-}
+function useDirectoryFilters(focusId: string | null) {
+ const [initial] = React.useState(() => initialDirectoryState(focusId))
+ const [filter, setFilter] = React.useState(initial.filter)
+ const [layer, setLayer] = React.useState("all")
+ const [sort, setSort] = React.useState("pop")
+ const [query, setQuery] = React.useState(initial.query)
+ const [debouncedQuery, setDebouncedQuery] = React.useState(initial.query)
-function dominantCategory(counts: PerCategoryCounts): ReportCategory | null {
- let best: ReportCategory | null = null
- let bestN = 0
- for (const c of REPORT_CATEGORIES) {
- const n = counts[c] ?? 0
- if (n > bestN) {
- bestN = n
- best = c
- }
- }
- return best
-}
+ React.useEffect(() => {
+ const timer = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS)
+ return () => clearTimeout(timer)
+ }, [query])
+ React.useEffect(() => {
+ if (!focusId) return
+ const next = initialDirectoryState(focusId)
+ setFilter(next.filter)
+ setQuery(next.query)
+ setDebouncedQuery(next.query)
+ }, [focusId])
-function needsAttention(dto: JurisdictionDirectoryDTO): boolean {
- return dto.reportsWaiting > 0 && dto.method === "none"
+ return { filter, setFilter, layer, setLayer, sort, setSort, query, setQuery, debouncedQuery }
}
-function fmtPop(n: number): string {
- if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k"
- return String(n)
-}
+const geoidOf = (item: JurisdictionDirectoryDTO) => item.geoid
-function JurisdictionRow({
- item,
- selected,
- onClick,
- showOldest = false,
+function DirectoryToolbar({
+ filters,
+ chips,
}: {
- item: JurisdictionDirectoryDTO
- selected: boolean
- onClick: () => void
- showOldest?: boolean
+ filters: ReturnType
+ chips: Record
}) {
- const isUnmapped = item.geoid === UNMAPPED_GEOID
- const overdue = showOldest && isOverdue(item.oldestReportAt)
- const needs = needsAttention(item)
- const dom = dominantCategory(item.perCategoryCounts)
- const pin = dom ? categoryPinSrc(dom) : null
- const onKeyDown = (event: React.KeyboardEvent) => {
- if (!isKeyboardActivationKey(event.key)) return
- event.preventDefault()
- onClick()
- }
-
- if (isUnmapped) {
- return (
-
-
-
-
-
-
- {item.org}
-
-
- Needs mapping
-
-
-
- {item.reportsWaiting} waiting
- ·
- location didn’t resolve to a jurisdiction
-
-
-
- {showOldest && item.oldestReportAt && (
-
- {overdue && }
- {fmtAge(item.oldestReportAt)}
-
- )}
-
-
-
-
-
- )
- }
-
+ const { filter, setFilter, layer, setLayer, sort, setSort, query, setQuery } = filters
return (
-
-
- {pin ? (
- // eslint-disable-next-line @next/next/no-img-element
-

- ) : (
-
- )}
-
-
-
- {item.org}
- {LAYER_LABEL[item.layer]}
- {item.status === "bounced" ? (
-
- Bounced
-
- ) : needs ? (
-
-
- Needs contact
-
- ) : (
-
- Routed
-
- )}
- {item.flaggedAt && (
-
- Flagged
-
- )}
- {item.geoid}
-
-
- {fmtPop(item.population)} pop
- ·
- {item.reportsWaiting} waiting
- ·
- {item.coverage}
-
+
+
setFilter(v as JurisdictionFilter)}
+ />
+
+
+
+ Type
+
-
- {showOldest ? (
-
- {overdue && }
- {fmtAge(item.oldestReportAt)}
-
- ) : (
-
{fmtRouted(item.lastRouted)}
- )}
-
-
-
+
+ Sort
+
)
}
-function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
- const toast = useToast()
- const patch = usePatchJurisdiction()
- const saveContacts = useSaveJurisdictionContacts()
- const geometry = useJurisdictionGeometry(dto.geoid)
-
- const [contacts, setContacts] = React.useState>(() => {
- const seed: Record = {}
- dto.contacts.forEach((c: DiscoveryContact) => {
- if (c.email) seed[c.category] = c.email
- })
- return seed
- })
- const [opNote, setOpNote] = React.useState("")
- const [defaultEmail, setDefaultEmail] = React.useState(dto.email ?? "")
- const [formUrl, setFormUrl] = React.useState(dto.form ?? "")
- const [handle, setHandle] = React.useState(dto.handle ?? "")
-
- const defaultTemplate = useForwardTemplateDefault()
- const [templateOpen, setTemplateOpen] = React.useState(false)
- const hasCustomTemplate =
- dto.forwardSubjectTemplate !== null || dto.forwardBodyTemplate !== null
- const storedDefault = defaultTemplate.data
- const templateFallback =
- storedDefault && (storedDefault.subjectTemplate !== null || storedDefault.bodyTemplate !== null)
- ? { subject: storedDefault.subjectTemplate ?? "", body: storedDefault.bodyTemplate ?? "" }
- : { subject: DEFAULT_FORWARD_SUBJECT_TEMPLATE, body: DEFAULT_FORWARD_BODY_TEMPLATE }
-
- const counts = dto.perCategoryCounts
- const setCat = (id: string, email: string) => setContacts((prev) => ({ ...prev, [id]: email }))
- const hasDefault = defaultEmail.trim() !== ""
- const missingContacts = REPORT_TYPES.filter(
- (c) => routingCount(counts, c.id) > 0 && !contacts[c.id] && !hasDefault,
- ).length
- const filledCount = REPORT_TYPES.filter((c) => contacts[c.id]).length
- const canSave = filledCount > 0 || hasDefault
- const needs = needsAttention(dto)
- const dom = dominantCategory(counts)
- const headPin = dom ? categoryPinSrc(dom) : null
- const isFlagged = dto.flaggedAt !== null
- const busy = saveContacts.isPending || patch.isPending
-
- const contactsPayload = (): Partial> => {
- const out: Partial> = {}
- REPORT_TYPES.forEach((c) => {
- const v = contacts[c.id]?.trim()
- if (v) out[c.id] = v
- })
- return out
- }
-
- const jurisdictionFields = (): { defaultEmails?: string[]; formUrl?: string | null } => {
- const out: { defaultEmails?: string[]; formUrl?: string | null } = {}
- const email = defaultEmail.trim()
- if (email) out.defaultEmails = [email]
- const url = formUrl.trim()
- if (url) out.formUrl = url
- return out
- }
-
- const onFlag = async () => {
- if (!isFlagged) {
- const reason = await promptDialog({ title: "Flag jurisdiction", label: "Reason (optional)" })
- if (reason === null) return
- patch.mutate(
- { geoid: dto.geoid, flagged: true, flagReason: reason || undefined },
- { onSuccess: () => toast(`${dto.org} flagged for review`) },
- )
- return
- }
- patch.mutate(
- { geoid: dto.geoid, flagged: false },
- { onSuccess: () => toast(`Flag cleared for ${dto.org}`) },
- )
- }
-
- const normalizedHandle = handle.trim().replace(/^@+/, "").toLowerCase()
- const handleChanged = normalizedHandle !== (dto.handle ?? "")
-
- const noteAndHandleFields = () => {
- const note = opNote.trim()
- return {
- ...(note ? { notes: note } : {}),
- ...(handleChanged ? { handle: normalizedHandle } : {}),
- }
- }
+function DirectoryListBody({
+ listQuery,
+ items,
+ selectedGeoid,
+ onSelect,
+ showOldest,
+}: {
+ listQuery: DirectoryList
+ items: JurisdictionDirectoryDTO[]
+ selectedGeoid: string | undefined
+ onSelect: (geoid: string) => void
+ showOldest: boolean
+}) {
+ const now = useNow()
+ return (
+ }
+ />
+ }
+ >
+ {items.map((item) => (
+
+ ))}
+
+
+ )
+}
- const onSaveDraft = () => {
- const contacts = contactsPayload()
- const jf = jurisdictionFields()
- const extras = noteAndHandleFields()
- if (
- Object.keys(contacts).length === 0 &&
- Object.keys(jf).length === 0 &&
- Object.keys(extras).length === 0
- ) {
- toast("Nothing to save yet")
- return
- }
- patch.mutate(
- { geoid: dto.geoid, contacts, ...jf, ...extras },
- {
- onSuccess: () => toast(`Draft saved for ${dto.org}`),
- onError: (err) => toast(toAppError(err).message),
- },
+function SelectedJurisdiction({
+ selected,
+ selectedId,
+ notListed,
+}: {
+ selected: JurisdictionDirectoryDTO | null
+ selectedId: string | null
+ notListed: boolean
+}) {
+ if (selected) {
+ return selected.geoid === UNMAPPED_GEOID ? (
+
+ ) : (
+
)
}
-
- const onSaveContacts = () => {
- if (!canSave) return
- const saveAndRoute = () =>
- saveContacts.mutate(
- { geoid: dto.geoid, contacts: contactsPayload(), ...jurisdictionFields() },
- {
- onSuccess: () =>
- toast(`Contacts saved for ${dto.org} · discovery task closed`),
- },
- )
- const extras = noteAndHandleFields()
- if (Object.keys(extras).length === 0) {
- saveAndRoute()
- return
- }
- patch.mutate(
- { geoid: dto.geoid, ...extras },
- {
- onSuccess: saveAndRoute,
- onError: (err) => toast(toAppError(err).message),
- },
+ if (notListed) {
+ return (
+ }
+ />
)
}
-
return (
-
-
-
- {headPin ? (
- // eslint-disable-next-line @next/next/no-img-element
-
- ) : (
-
- )}
-
-
-
- {LAYER_LABEL[dto.layer]} · Jurisdiction · GEOID {dto.geoid}
-
-
{dto.org}
-
- {dto.status === "bounced" ? (
-
- Bounced
-
- ) : needs ? (
-
-
- Needs contact
-
- ) : (
-
- Routed
-
- )}
-
-
-
-
- { }
-
-
Jurisdiction
-
-
- {geometry.data ? (
-
- ) : (
-
- {geometry.isLoading ? "Loading boundary…" : dto.org}
-
- )}
-
-
-
-
Population
-
{dto.population.toLocaleString()}
-
- {dto.population > 0 ? "US Census ACS" : "Not available"}
-
-
-
-
Reports waiting
-
{dto.reportsWaiting}
-
{LAYER_LABEL[dto.layer]}
-
-
-
-
-
- { }
-
-
-
-
- { }
-
-
Discussion @handle
-
-
-
- @
-
- setHandle(e.target.value)}
- autoCapitalize="none"
- autoCorrect="off"
- spellCheck={false}
- aria-label="Jurisdiction discussion handle"
- />
-
-
- Residents can tag “@{normalizedHandle || "handle"}” in a report’s discussion to forward it
- to this jurisdiction. Lowercase letters, numbers, and underscores; leave blank to clear.
- Saved by either “Save draft” or “Save & route”.
-
-
-
-
- { }
-
-
Default contact
-
-
-
- setDefaultEmail(e.target.value)}
- />
-
-
-
- setFormUrl(e.target.value)}
- />
-
-
- The default email routes any category without its own contact below; the form URL is the
- city’s public reporting page.
-
-
-
-
- { }
-
-
- Routing contacts
-
- {missingContacts > 0
- ? `${missingContacts} type${missingContacts === 1 ? "" : "s"} with no contact`
- : "all routed"}
-
-
-
-
- {REPORT_TYPES.map((c) => {
- const n = routingCount(counts, c.id)
- const attention = n > 0 && !contacts[c.id] && !hasDefault
- return (
-
-
-
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
-
{c.label}
-
0 ? "" : "zero"} ${attention ? "warn" : ""}`}
- >
- {n} {n === 1 ? "report" : "reports"}
-
-
- {c.types &&
{c.types}
}
-
-
- setCat(c.id, e.target.value)}
- />
- {attention && (
-
-
-
- )}
-
-
- )
- })}
-
-
- Counts are reports waiting per category · the grey line lists the report types neighbors
- pick that fold into it · highlighted categories have reports but no contact yet.
-
-
-
-
-
-
Email template
-
-
-
- {hasCustomTemplate ? "Custom template" : "Using the default template"}
-
-
-
-
-
-
-
-
-
- { }
-
-
- {filledCount} of {REPORT_TYPES.length} contacts set
-
-
-
-
-
-
-
-
-
-
- Save & route saves the contacts, the note and the @handle, closes the discovery
- task, and queues an outreach digest to this jurisdiction when outreach digests are enabled. It
- does not email the reports already waiting — send each of those from its report.{" "}
- Save draft saves the same fields and leaves the discovery task open.
-
-
-
-
setTemplateOpen(false)}
- title={`Forwarding email for ${dto.org}`}
- subtitle="Overrides the default forwarding email for this jurisdiction. Clear both fields to go back to the default."
- initial={{ subject: dto.forwardSubjectTemplate, body: dto.forwardBodyTemplate }}
- fallback={templateFallback}
- fallbackLabel="default template"
- pending={patch.isPending}
- onSave={({ subject, body }) =>
- patch.mutate(
- {
- geoid: dto.geoid,
- forwardSubjectTemplate: subject,
- forwardBodyTemplate: body,
- },
- {
- onSuccess: () => {
- toast(`Email template saved for ${dto.org}`)
- setTemplateOpen(false)
- },
- onError: (err) => toast(toAppError(err).message),
- },
- )
- }
- />
-
- )
-}
-
-function UnmappedDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
- const counts = dto.perCategoryCounts
- const waiting = REPORT_TYPES.filter((c) => routingCount(counts, c.id) > 0)
- return (
-
-
-
-
-
-
-
Triage · location did not resolve
-
{dto.org}
-
-
-
- {dto.reportsWaiting} waiting
-
-
-
-
-
What this is
-
-
- These reports’ locations didn’t resolve to any jurisdiction on file, so they can’t be routed to
- a city contact yet. This usually means jurisdiction boundaries haven’t been loaded (run the
- jurisdictions seed/ingest), or the pins fall outside all known coverage.
-
-
-
-
-
-
Waiting reports by type
-
- {waiting.length === 0 ? (
-
Nothing waiting.
- ) : (
-
- {waiting.map((c) => {
- const n = routingCount(counts, c.id)
- return (
-
-
-
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
-
{c.label}
-
- {n} {n === 1 ? "report" : "reports"}
-
-
-
- )
- })}
-
- )}
-
- Once jurisdictions are loaded, new pins route automatically; existing unmapped pins may need a
- re-resolve pass.
-
-
-
-
+ }
+ />
)
}
export function DiscoveryPage({ focusId }: SectionPageProps) {
- const [filter, setFilter] = React.useState("attention")
- const [layer, setLayer] = React.useState<"all" | JurisdictionLayer>("all")
- const [sort, setSort] = React.useState("pop")
- const [query, setQuery] = React.useState(focusId ?? "")
- const [selId, setSelId] = React.useState(focusId)
-
- const [debouncedQ, setDebouncedQ] = React.useState("")
- React.useEffect(() => {
- const t = setTimeout(() => setDebouncedQ(query.trim()), 250)
- return () => clearTimeout(t)
- }, [query])
-
- const serverFilter =
- filter === "attention" ? "needs_mapping" : filter === "clear" ? "routed" : "all"
- const effectiveSort = getJurisdictionSort(filter, sort)
- const serverSort = effectiveSort === "reports" ? "reports" : effectiveSort === "oldest" ? "oldest" : "population"
- const oldestView = filter === "attention"
- const listQuery = useJurisdictionDirectory({
- filter: serverFilter,
- sort: serverSort,
- ...(layer !== "all" ? { layer } : {}),
- ...(debouncedQ ? { q: debouncedQ } : {}),
- })
- const needsMappingCountQuery = useJurisdictionDirectory({
- filter: "needs_mapping",
- sort: "oldest",
- ...(layer !== "all" ? { layer } : {}),
- ...(debouncedQ ? { q: debouncedQ } : {}),
- })
+ const filters = useDirectoryFilters(focusId)
+ const { filter, layer, sort, debouncedQuery } = filters
+ const listParams = toDirectoryQuery(filter, sort, layer, debouncedQuery)
+ const listQuery = useJurisdictionListInfinite(listParams)
+ const needsMappingQuery = useJurisdictionListInfinite(
+ toDirectoryQuery("attention", sort, layer, debouncedQuery),
+ )
const items = React.useMemo(
- () => listQuery.data?.pages.flatMap((p) => p.items) ?? [],
+ () => flatPages(listQuery.data),
[listQuery.data],
)
- const total = listQuery.data?.pages[0]?.total ?? null
- const facets = listQuery.data?.pages[0]?.facets ?? null
- const needsMappingTotal = needsMappingCountQuery.data?.pages[0]?.total ?? null
- const needsMappingCountDisplay = getNeedsMappingCountDisplay({
- count: needsMappingTotal,
- isLoading: needsMappingCountQuery.isLoading,
- isError: needsMappingCountQuery.isError,
+ const firstPage = listQuery.data?.pages[0]
+ const counts = directoryCounts({
+ filter,
+ total: firstPage?.total ?? null,
+ facets: firstPage?.facets ?? null,
+ needsMapping: countDisplay({
+ count: needsMappingQuery.data?.pages[0]?.total ?? null,
+ isLoading: needsMappingQuery.isLoading,
+ isError: needsMappingQuery.isError,
+ }),
+ list: { isLoading: listQuery.isLoading, isError: listQuery.isError },
+ loadedCount: items.length,
})
- const allTotal = facets ? facets.routed + facets.unrouted : filter === "all" ? total : null
- React.useEffect(() => {
- if (focusId) {
- setSelId(focusId)
- setQuery(focusId)
- }
- }, [focusId])
-
- const focused = selId ? (items.find((x) => x.geoid === selId) ?? null) : null
- const holdingFocus = selId !== null && selId === focusId
- const selected = focused ?? (holdingFocus ? null : (items[0] ?? null))
-
- const catFilters = [
- { value: "attention", label: "Needs mapping", count: needsMappingCountDisplay },
- { value: "clear", label: "Routed", count: facets?.routed ?? 0 },
- { value: "all", label: "All", count: allTotal ?? 0 },
- ]
-
- const onFilterChange = (v: JurisdictionFilter) => {
- setFilter(v)
- // The "needs mapping" view is about the most-overdue reports first, so default it to the
- // oldest-first sort; leaving the view falls back to population unless the operator picked reports.
- if (v === "attention") setSort("oldest")
- else if (sort === "oldest") setSort("pop")
- }
- const headerCount =
- filter === "attention" ? needsMappingTotal : filter === "clear" ? facets?.routed : total
- const headerCountDisplay = filter === "attention" ? needsMappingCountDisplay : headerCount ?? items.length
+ // There is no by-id read here, so a pick that a filter or search leaves out stays open as last listed.
+ const { selectedId, setSelectedId, selectedItem: selected } = useSelection({
+ focusId,
+ list: listQuery,
+ items,
+ getId: geoidOf,
+ listKey: JSON.stringify([listParams.filter, listParams.sort, layer, debouncedQuery]),
+ keepLastSeen: true,
+ })
+ const notListed =
+ selectedId !== null && selected === null && !listQuery.isLoading && !listQuery.isError
return (
<>
@@ -850,116 +243,29 @@ export function DiscoveryPage({ focusId }: SectionPageProps) {
}
/>
-
-
onFilterChange(v as JurisdictionFilter)}
- />
-
-
-
- setQuery(e.target.value)}
- />
-
-
- Type
-
-
-
- Sort
-
-
-
+
-
-
-
- {typeof headerCountDisplay === "number"
- ? headerCountDisplay.toLocaleString()
- : headerCountDisplay}{" "}
- {headerCountDisplay === 1 ? "jurisdiction" : "jurisdictions"}
-
-
-
click a row →
-
-
- {listQuery.isLoading ? (
-
- ) : listQuery.isError ? (
- listQuery.refetch()} />
- ) : items.length === 0 ? (
- }
- />
- ) : (
- <>
- {items.map((item) => (
- setSelId(item.geoid)}
- showOldest={oldestView}
- />
- ))}
- {listQuery.hasNextPage && (
-
- )}
- >
- )}
-
-
+
+ {typeof counts.header === "number" ? counts.header.toLocaleString() : counts.header}{" "}
+ {counts.header === 1 ? "jurisdiction" : "jurisdictions"}
+ >
+ }
+ meta="click a row →"
+ >
+
+
- {selected ? (
- selected.geoid === UNMAPPED_GEOID ? (
-
- ) : (
-
- )
- ) : (
- }
- />
- )}
+
>
diff --git a/apps/admin/src/features/discovery/discovery-payloads.test.ts b/apps/admin/src/features/discovery/discovery-payloads.test.ts
new file mode 100644
index 0000000..166ec66
--- /dev/null
+++ b/apps/admin/src/features/discovery/discovery-payloads.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from "vitest"
+
+import {
+ afterContactsSaved,
+ contactsPayload,
+ jurisdictionFields,
+ noteAndHandleFields,
+ parseHandle,
+ partialSaveMessage,
+ routingContactsPayload,
+ withContactEdit,
+} from "./discovery-payloads"
+
+describe("contacts payload", () => {
+ it("sends the trimmed contacts the operator edited", () => {
+ const current = { trash: " trash@city.gov ", graffiti: "g@city.gov", hazard: "h@city.gov" }
+ expect(contactsPayload({ trash: 1, hazard: 2 }, current)).toEqual({
+ trash: "trash@city.gov",
+ hazard: "h@city.gov",
+ })
+ })
+
+ it("sends null for an edited category that is now empty, so the server deletes it", () => {
+ expect(contactsPayload({ trash: 1 }, { trash: "" })).toEqual({ trash: null })
+ expect(contactsPayload({ trash: 1 }, { trash: " " })).toEqual({ trash: null })
+ })
+
+ it("leaves categories the operator did not edit out, so a contact changed meanwhile survives", () => {
+ expect(contactsPayload({}, { trash: "old@city.gov", graffiti: "" })).toEqual({})
+ })
+
+ it("routes with every contact shown, edited or not, plus null for an edited category now empty", () => {
+ const current = { trash: " trash@city.gov ", graffiti: "", hazard: "h@city.gov", dumping: "" }
+ expect(routingContactsPayload({ hazard: 1, graffiti: 1 }, current)).toEqual({
+ trash: "trash@city.gov",
+ hazard: "h@city.gov",
+ graffiti: null,
+ })
+ expect(routingContactsPayload({}, { trash: "old@city.gov" })).toEqual({ trash: "old@city.gov" })
+ })
+
+ it("counts every edit to a category", () => {
+ expect(withContactEdit(withContactEdit({}, "trash"), "trash")).toEqual({ trash: 2 })
+ })
+
+ it("keeps an edit made while the save was in flight marked for the next save", () => {
+ expect(afterContactsSaved({ trash: 2, graffiti: 1 }, { trash: 1, graffiti: 1 })).toEqual({ trash: 2 })
+ expect(afterContactsSaved({ trash: 1 }, { trash: 1 })).toEqual({})
+ })
+})
+
+describe("jurisdiction fields", () => {
+ it("sends a trimmed default email and form url and omits blanks", () => {
+ expect(jurisdictionFields(" reports@city.gov ", " https://city.gov/report ")).toEqual({
+ defaultEmails: ["reports@city.gov"],
+ formUrl: "https://city.gov/report",
+ })
+ expect(jurisdictionFields("", " ")).toEqual({})
+ })
+})
+
+describe("@handle", () => {
+ it("normalizes the way the server does", () => {
+ expect(parseHandle("@SF ")).toEqual({ value: "sf", error: null })
+ expect(parseHandle("@ sf")).toEqual({ value: "sf", error: null })
+ expect(parseHandle(" ")).toEqual({ value: "", error: null })
+ })
+
+ it("reports the server's rule for an invalid handle", () => {
+ expect(parseHandle("a").error).toBe(
+ "Handle must be 2-40 characters using lowercase letters, numbers, or underscores.",
+ )
+ expect(parseHandle("san francisco").error).not.toBeNull()
+ })
+})
+
+describe("note and handle fields", () => {
+ it("sends a trimmed note and a changed handle", () => {
+ expect(noteAndHandleFields(" Called the clerk ", "sf", null)).toEqual({
+ notes: "Called the clerk",
+ handle: "sf",
+ })
+ })
+
+ it("omits an empty note and an unchanged handle", () => {
+ expect(noteAndHandleFields(" ", "sf", "sf")).toEqual({})
+ })
+
+ it("sends an empty handle to clear a stored one", () => {
+ expect(noteAndHandleFields("", "", "sf")).toEqual({ handle: "" })
+ })
+})
+
+describe("partial Save & route message", () => {
+ it("names what was saved before the contacts failed", () => {
+ expect(partialSaveMessage({ notes: "x" }, "Server down")).toBe(
+ "The note was saved, but the contacts were not: Server down",
+ )
+ expect(partialSaveMessage({ handle: "sf" }, "Server down")).toBe(
+ "The @handle was saved, but the contacts were not: Server down",
+ )
+ expect(partialSaveMessage({ notes: "x", handle: "sf" }, "Server down")).toBe(
+ "The note and @handle were saved, but the contacts were not: Server down",
+ )
+ })
+})
\ No newline at end of file
diff --git a/apps/admin/src/features/discovery/discovery-payloads.ts b/apps/admin/src/features/discovery/discovery-payloads.ts
new file mode 100644
index 0000000..d85be8e
--- /dev/null
+++ b/apps/admin/src/features/discovery/discovery-payloads.ts
@@ -0,0 +1,97 @@
+import { JurisdictionHandleSchema, type ReportCategory } from "@civfix/shared"
+
+import { REPORT_CATEGORIES } from "@/lib/category"
+
+type ContactDraft = Readonly>>
+
+/** Per category, how many edits the operator has made since that contact was last saved. */
+export type ContactEdits = Readonly>>
+
+// Save draft sends only the categories the operator edited since the last save, so it never overwrites
+// or deletes a contact another operator changed meanwhile. The server deletes a category contact only on an
+// explicit null, so an edited category that is now empty is sent as null, whatever it held at load.
+export function contactsPayload(
+ edits: ContactEdits,
+ current: ContactDraft,
+): Partial> {
+ const out: Partial> = {}
+ for (const category of REPORT_CATEGORIES) {
+ if (!edits[category]) continue
+ out[category] = current[category]?.trim() || null
+ }
+ return out
+}
+
+// Save & route confirms the routing set the operator sees, and the server checks only the request body
+// for a routable contact, so every contact shown goes out, not just the edited ones.
+export function routingContactsPayload(
+ edits: ContactEdits,
+ current: ContactDraft,
+): Partial> {
+ const out: Partial> = {}
+ for (const category of REPORT_CATEGORIES) {
+ const email = current[category]?.trim()
+ if (email) out[category] = email
+ else if (edits[category]) out[category] = null
+ }
+ return out
+}
+
+export function withContactEdit(edits: ContactEdits, category: string): ContactEdits {
+ return { ...edits, [category]: (edits[category] ?? 0) + 1 }
+}
+
+/**
+ * The edits still unsaved once a save of `sent` succeeds: an edit made while that save was in flight
+ * changed the category's count, so it stays marked and goes out with the next save.
+ */
+export function afterContactsSaved(edits: ContactEdits, sent: ContactEdits): ContactEdits {
+ const next: Partial> = {}
+ for (const [category, count] of Object.entries(edits)) {
+ if (count !== undefined && count !== sent[category]) next[category] = count
+ }
+ return next
+}
+
+export function jurisdictionFields(
+ defaultEmail: string,
+ formUrl: string,
+): { defaultEmails?: string[]; formUrl?: string } {
+ const email = defaultEmail.trim()
+ const url = formUrl.trim()
+ return {
+ ...(email ? { defaultEmails: [email] } : {}),
+ ...(url ? { formUrl: url } : {}),
+ }
+}
+
+export function parseHandle(input: string): { value: string; error: string | null } {
+ const parsed = JurisdictionHandleSchema.safeParse(input)
+ if (parsed.success) return { value: parsed.data ?? "", error: null }
+ return { value: "", error: parsed.error.issues[0]!.message }
+}
+
+export interface SavedExtras {
+ notes?: string
+ handle?: string
+}
+
+export function noteAndHandleFields(
+ note: string,
+ handle: string,
+ storedHandle: string | null,
+): SavedExtras {
+ const trimmed = note.trim()
+ return {
+ ...(trimmed ? { notes: trimmed } : {}),
+ ...(handle !== (storedHandle ?? "") ? { handle } : {}),
+ }
+}
+
+export function partialSaveMessage(saved: SavedExtras, reason: string): string {
+ const parts: string[] = []
+ if (saved.notes !== undefined) parts.push("note")
+ if (saved.handle !== undefined) parts.push("@handle")
+ const verb = parts.length > 1 ? "were" : "was"
+ return `The ${parts.join(" and ")} ${verb} saved, but the contacts were not: ${reason}`
+}
\ No newline at end of file
diff --git a/apps/admin/src/features/discovery/discovery-ui-state.test.ts b/apps/admin/src/features/discovery/discovery-ui-state.test.ts
index 8f9e9a8..f87fea4 100644
--- a/apps/admin/src/features/discovery/discovery-ui-state.test.ts
+++ b/apps/admin/src/features/discovery/discovery-ui-state.test.ts
@@ -1,17 +1,56 @@
import { describe, expect, it } from "vitest"
-import { getJurisdictionSort, getNeedsMappingCountDisplay } from "./discovery-ui-state"
+import { EMPTY_VALUE } from "@/lib/empty-value"
+import {
+ countDisplay,
+ effectiveJurisdictionSort,
+ initialDirectoryState,
+ pickSelected,
+} from "./discovery-ui-state"
describe("Jurisdictions UI state", () => {
it("always uses oldest sort for the Needs mapping filter", () => {
- expect(getJurisdictionSort("attention", "pop")).toBe("oldest")
- expect(getJurisdictionSort("attention", "reports")).toBe("oldest")
- expect(getJurisdictionSort("attention", "oldest")).toBe("oldest")
+ expect(effectiveJurisdictionSort("attention", "pop")).toBe("oldest")
+ expect(effectiveJurisdictionSort("attention", "reports")).toBe("oldest")
+ expect(effectiveJurisdictionSort("attention", "oldest")).toBe("oldest")
})
it("shows loading and unavailable count labels instead of false zeroes", () => {
- expect(getNeedsMappingCountDisplay({ count: null, isLoading: true, isError: false })).toBe("Loading…")
- expect(getNeedsMappingCountDisplay({ count: null, isLoading: false, isError: true })).toBe("—")
- expect(getNeedsMappingCountDisplay({ count: 0, isLoading: false, isError: false })).toBe(0)
+ expect(countDisplay({ count: null, isLoading: true, isError: false })).toBe("Loading…")
+ expect(countDisplay({ count: null, isLoading: false, isError: true })).toBe(EMPTY_VALUE)
+ expect(countDisplay({ count: 0, isLoading: false, isError: false })).toBe(0)
})
})
+
+describe("initial directory state", () => {
+ it("opens on Needs mapping with an empty search", () => {
+ expect(initialDirectoryState(null)).toEqual({ filter: "attention", query: "" })
+ })
+
+ it("opens a deep link on All with its geoid as the search, since a bounced or routed place is never in Needs mapping", () => {
+ expect(initialDirectoryState("0667000")).toEqual({ filter: "all", query: "0667000" })
+ })
+})
+
+describe("selected jurisdiction", () => {
+ const la = { geoid: "0644000" }
+ const sf = { geoid: "0667000" }
+
+ it("shows no row while nothing is selected, so a cleared pick never falls to another row", () => {
+ expect(pickSelected([la, sf], null, null)).toBeNull()
+ expect(pickSelected([la, sf], null, la)).toBeNull()
+ })
+
+ it("shows the selected row from the list", () => {
+ expect(pickSelected([la, sf], "0667000", null)).toBe(sf)
+ })
+
+ it("keeps showing the selected row after it leaves the list", () => {
+ expect(pickSelected([la], "0667000", sf)).toBe(sf)
+ })
+
+ it("never falls back to another row for a selection it has not seen", () => {
+ expect(pickSelected([la], "0667000", null)).toBeNull()
+ expect(pickSelected([la], "0667000", la)).toBeNull()
+ })
+})
\ No newline at end of file
diff --git a/apps/admin/src/features/discovery/discovery-ui-state.ts b/apps/admin/src/features/discovery/discovery-ui-state.ts
index 719b9a9..45e9103 100644
--- a/apps/admin/src/features/discovery/discovery-ui-state.ts
+++ b/apps/admin/src/features/discovery/discovery-ui-state.ts
@@ -1,14 +1,19 @@
+import type { JurisdictionLayer, JurisdictionListQuery } from "@civfix/shared"
+
+import { resolveSelected } from "@/hooks/use-selection"
+import { EMPTY_VALUE } from "@/lib/empty-value"
+
export type JurisdictionFilter = "all" | "attention" | "clear"
export type JurisdictionSort = "pop" | "reports" | "oldest"
-export function getJurisdictionSort(
+export function effectiveJurisdictionSort(
filter: JurisdictionFilter,
selectedSort: JurisdictionSort,
): JurisdictionSort {
return filter === "attention" ? "oldest" : selectedSort
}
-export function getNeedsMappingCountDisplay({
+export function countDisplay({
count,
isLoading,
isError,
@@ -16,8 +21,83 @@ export function getNeedsMappingCountDisplay({
count: number | null
isLoading: boolean
isError: boolean
-}): number | "Loading…" | "—" {
+}): number | "Loading…" | typeof EMPTY_VALUE {
if (isLoading) return "Loading…"
- if (isError || count === null) return "—"
+ if (isError || count === null) return EMPTY_VALUE
return count
}
+
+// A deep link comes from a bounced mail thread, a routed report or a gov claim, none of which sit in
+// Needs mapping, so it opens on All with the geoid as the search.
+export function initialDirectoryState(focusId: string | null): {
+ filter: JurisdictionFilter
+ query: string
+} {
+ return focusId ? { filter: "all", query: focusId } : { filter: "attention", query: "" }
+}
+
+export function pickSelected(
+ items: readonly T[],
+ selId: string | null,
+ lastSeen: T | null,
+): T | null {
+ return resolveSelected(items, selId, lastSeen, (item) => item.geoid)
+}
+
+export type DirectoryQuery = Pick
+
+const SERVER_FILTER: Record> = {
+ attention: "needs_mapping",
+ clear: "routed",
+ all: "all",
+}
+
+const SERVER_SORT: Record> = {
+ pop: "population",
+ reports: "reports",
+ oldest: "oldest",
+}
+
+export function toDirectoryQuery(
+ filter: JurisdictionFilter,
+ sort: JurisdictionSort,
+ layer: "all" | JurisdictionLayer,
+ q: string,
+): DirectoryQuery {
+ return {
+ filter: SERVER_FILTER[filter],
+ sort: SERVER_SORT[effectiveJurisdictionSort(filter, sort)],
+ ...(layer !== "all" ? { layer } : {}),
+ ...(q ? { q } : {}),
+ }
+}
+
+export type CountDisplay = ReturnType
+
+export function directoryCounts({
+ filter,
+ total,
+ facets,
+ needsMapping,
+ list,
+ loadedCount,
+}: {
+ filter: JurisdictionFilter
+ total: number | null
+ facets: { routed: number; unrouted: number } | null
+ needsMapping: CountDisplay
+ list: { isLoading: boolean; isError: boolean }
+ loadedCount: number
+}): { chips: Record; header: CountDisplay } {
+ const listCount = (count: number | null) => countDisplay({ count, ...list })
+ const allTotal = facets ? facets.routed + facets.unrouted : filter === "all" ? total : null
+ const listedTotal = filter === "clear" ? facets?.routed : total
+ return {
+ chips: {
+ attention: needsMapping,
+ clear: listCount(facets?.routed ?? null),
+ all: listCount(allTotal),
+ },
+ header: filter === "attention" ? needsMapping : listedTotal ?? loadedCount,
+ }
+}
diff --git a/apps/admin/src/features/discovery/jurisdiction-detail-panels.tsx b/apps/admin/src/features/discovery/jurisdiction-detail-panels.tsx
new file mode 100644
index 0000000..fd82cd9
--- /dev/null
+++ b/apps/admin/src/features/discovery/jurisdiction-detail-panels.tsx
@@ -0,0 +1,351 @@
+"use client"
+
+import dynamic from "next/dynamic"
+import type { JurisdictionDirectoryDTO, PerCategoryCounts } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { useJurisdictionGeometry } from "@/features/discovery/use-discovery"
+import {
+ LAYER_LABEL,
+ REPORT_TYPES,
+ dominantCategory,
+ routingCount,
+} from "@/features/discovery/jurisdiction-view"
+import { RoutingStatusPill } from "@/features/discovery/routing-status-pill"
+import { categoryPinSrc } from "@/lib/category"
+
+const BoundaryMap = dynamic(
+ () => import("@/components/map/boundary-map").then((m) => m.BoundaryMap),
+ { ssr: false },
+)
+
+export function JurisdictionHeader({ dto }: { dto: JurisdictionDirectoryDTO }) {
+ const dominant = dominantCategory(dto.perCategoryCounts)
+ const headPin = dominant ? categoryPinSrc(dominant) : null
+ return (
+
+
+ {headPin ? (
+
+ ) : (
+
+ )}
+
+
+
+ {LAYER_LABEL[dto.layer]} · Jurisdiction · GEOID {dto.geoid}
+
+
{dto.org}
+
+
+
+ )
+}
+
+export function BoundaryPanel({ dto }: { dto: JurisdictionDirectoryDTO }) {
+ const geometry = useJurisdictionGeometry(dto.geoid)
+ return (
+
+
Jurisdiction
+
+
+ {geometry.data ? (
+
+ ) : (
+
{geometry.isLoading ? "Loading boundary…" : dto.org}
+ )}
+
+
+
+
Population
+
{dto.population.toLocaleString()}
+
+ {dto.population > 0 ? "US Census ACS" : "Not available"}
+
+
+
+
Reports waiting
+
{dto.reportsWaiting}
+
{LAYER_LABEL[dto.layer]}
+
+
+
+
+ )
+}
+
+export function NotesPanel({ note, onChange }: { note: string; onChange: (note: string) => void }) {
+ return (
+
+ )
+}
+
+export function HandleField({
+ handle,
+ parsed,
+ onChange,
+}: {
+ handle: string
+ parsed: { value: string; error: string | null }
+ onChange: (handle: string) => void
+}) {
+ return (
+
+
Discussion @handle
+
+
+
+ @
+
+ onChange(e.target.value)}
+ autoCapitalize="none"
+ autoCorrect="off"
+ spellCheck={false}
+ aria-label="Jurisdiction discussion handle"
+ />
+
+ {parsed.error && (
+
+ {parsed.error}
+
+ )}
+
+ Residents can tag “@{parsed.value || "handle"}” in a report’s discussion to forward
+ it to this jurisdiction. Lowercase letters, numbers, and underscores; leave blank to clear.
+ Saved by either “Save draft” or “Save & route”.
+
+
+
+ )
+}
+
+export function DefaultContactFields({
+ email,
+ formUrl,
+ onEmailChange,
+ onFormUrlChange,
+}: {
+ email: string
+ formUrl: string
+ onEmailChange: (email: string) => void
+ onFormUrlChange: (url: string) => void
+}) {
+ return (
+
+
Default contact
+
+
+
+ onEmailChange(e.target.value)}
+ />
+
+
+
+ onFormUrlChange(e.target.value)}
+ />
+
+
+ The default email routes any category without its own contact below; the form URL is the
+ city’s public reporting page. Clearing the default email or form URL here does not remove
+ the saved one.
+
+
+
+ )
+}
+
+export function CategoryContactsGrid({
+ counts,
+ contacts,
+ hasDefault,
+ missingCount,
+ onChange,
+}: {
+ counts: PerCategoryCounts
+ contacts: Record
+ hasDefault: boolean
+ missingCount: number
+ onChange: (category: string, email: string) => void
+}) {
+ return (
+
+
+ Routing contacts
+
+ {missingCount > 0
+ ? `${missingCount} type${missingCount === 1 ? "" : "s"} with no contact`
+ : "all routed"}
+
+
+
+
+ {REPORT_TYPES.map((c) => {
+ const n = routingCount(counts, c.id)
+ const attention = n > 0 && !contacts[c.id] && !hasDefault
+ return (
+
+
+
+
+
+
{c.label}
+
0 ? "" : "zero"} ${attention ? "warn" : ""}`}>
+ {n} {n === 1 ? "report" : "reports"}
+
+
+ {c.types &&
{c.types}
}
+
+
+ onChange(c.id, e.target.value)}
+ />
+ {attention && (
+
+
+
+ )}
+
+
+ )
+ })}
+
+
+ Counts are reports waiting per category · the grey line lists the report types neighbors
+ pick that fold into it · highlighted categories have reports but no contact yet.
+
+
+
+ )
+}
+
+export function TemplateSection({
+ hasCustomTemplate,
+ loading,
+ onEdit,
+}: {
+ hasCustomTemplate: boolean
+ loading: boolean
+ onEdit: () => void
+}) {
+ return (
+
+
Email template
+
+
+
+ {hasCustomTemplate ? "Custom template" : "Using the default template"}
+
+
+
+
+
+
+ )
+}
+
+export function DetailActions({
+ filledCount,
+ canSave,
+ saveBlocked,
+ isFlagged,
+ flagging,
+ onFlag,
+ onSaveDraft,
+ onSaveAndRoute,
+}: {
+ filledCount: number
+ canSave: boolean
+ saveBlocked: boolean
+ isFlagged: boolean
+ flagging: boolean
+ onFlag: () => void
+ onSaveDraft: () => void
+ onSaveAndRoute: () => void
+}) {
+ return (
+ <>
+
+
+ {filledCount} of {REPORT_TYPES.length} contacts set
+
+
+
+
+
+
+
+
+
+
+ Save & route saves the contacts, the note and the @handle, closes the discovery
+ task, and queues an outreach digest to this jurisdiction when outreach digests are enabled. It
+ does not email the reports already waiting; send each of those from its report.{" "}
+ Save draft saves the same fields and leaves the discovery task open.
+
+
+ >
+ )
+}
diff --git a/apps/admin/src/features/discovery/jurisdiction-detail.tsx b/apps/admin/src/features/discovery/jurisdiction-detail.tsx
new file mode 100644
index 0000000..78b1e65
--- /dev/null
+++ b/apps/admin/src/features/discovery/jurisdiction-detail.tsx
@@ -0,0 +1,120 @@
+"use client"
+
+import * as React from "react"
+import {
+ DEFAULT_FORWARD_BODY_TEMPLATE,
+ DEFAULT_FORWARD_SUBJECT_TEMPLATE,
+ type GetForwardTemplateDefaultResponse,
+ type JurisdictionDirectoryDTO,
+} from "@civfix/shared"
+
+import {
+ BoundaryPanel,
+ CategoryContactsGrid,
+ DefaultContactFields,
+ DetailActions,
+ HandleField,
+ JurisdictionHeader,
+ NotesPanel,
+ TemplateSection,
+} from "@/features/discovery/jurisdiction-detail-panels"
+import { useJurisdictionEditor } from "@/features/discovery/use-jurisdiction-editor"
+import { ForwardTemplateModal } from "@/features/mail/forward-template-modal"
+import type { ForwardTemplatePair } from "@/features/mail/forward-template-modal-state"
+import { useForwardTemplateDefault } from "@/features/mail/use-mail"
+import { errorMessage } from "@/lib/error-messages"
+import { useToast } from "@/store/ui-store"
+
+function templateFallback(stored: GetForwardTemplateDefaultResponse | undefined): ForwardTemplatePair {
+ if (stored && (stored.subjectTemplate !== null || stored.bodyTemplate !== null)) {
+ return { subject: stored.subjectTemplate ?? "", body: stored.bodyTemplate ?? "" }
+ }
+ return { subject: DEFAULT_FORWARD_SUBJECT_TEMPLATE, body: DEFAULT_FORWARD_BODY_TEMPLATE }
+}
+
+export function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
+ const toast = useToast()
+ const editor = useJurisdictionEditor(dto)
+ const { patch } = editor
+ const defaultTemplate = useForwardTemplateDefault()
+ const [templateOpen, setTemplateOpen] = React.useState(false)
+
+ const openTemplate = () => {
+ if (defaultTemplate.isError) {
+ toast(errorMessage(defaultTemplate.error), "error")
+ return
+ }
+ setTemplateOpen(true)
+ }
+
+ return (
+
+
+
+
+
+
+
+
setTemplateOpen(false)}
+ title={`Forwarding email for ${dto.org}`}
+ subtitle="Overrides the default forwarding email for this jurisdiction. Clear both fields to go back to the default."
+ initial={{ subject: dto.forwardSubjectTemplate, body: dto.forwardBodyTemplate }}
+ fallback={templateFallback(defaultTemplate.data)}
+ fallbackLabel="default template"
+ pending={patch.isPending}
+ onSave={({ subject, body }) =>
+ patch.mutate(
+ {
+ request: {
+ geoid: dto.geoid,
+ forwardSubjectTemplate: subject,
+ forwardBodyTemplate: body,
+ },
+ org: dto.org,
+ action: "template",
+ },
+ { onSuccess: () => setTemplateOpen(false) },
+ )
+ }
+ />
+
+ )
+}
diff --git a/apps/admin/src/features/discovery/jurisdiction-row.test.tsx b/apps/admin/src/features/discovery/jurisdiction-row.test.tsx
new file mode 100644
index 0000000..075f35a
--- /dev/null
+++ b/apps/admin/src/features/discovery/jurisdiction-row.test.tsx
@@ -0,0 +1,47 @@
+import { render } from "@testing-library/react"
+import type { JurisdictionDirectoryDTO } from "@civfix/shared"
+import { describe, expect, it } from "vitest"
+
+import { MINUTE_MS } from "@/lib/timing"
+import { JurisdictionRow } from "@/features/discovery/jurisdiction-row"
+
+const HOUR_MS = 60 * MINUTE_MS
+const NOW = Date.parse("2026-09-20T15:00:00.000Z")
+
+const ITEM = {
+ geoid: "0644000",
+ org: "Los Angeles",
+ dept: null,
+ email: null,
+ form: null,
+ method: "none",
+ status: "pending",
+ coverage: "City limits",
+ lastRouted: null,
+ layer: "place",
+ population: 3_898_747,
+ reportsWaiting: 4,
+ perCategoryCounts: { trash: 3, graffiti: 1 },
+ contacts: [],
+ flaggedAt: null,
+ handle: null,
+ oldestReportAt: new Date(NOW - 23 * HOUR_MS).toISOString(),
+ forwardSubjectTemplate: null,
+ forwardBodyTemplate: null,
+} satisfies JurisdictionDirectoryDTO
+
+describe("JurisdictionRow waiting age", () => {
+ it("reads its age and overdue state from the now prop", () => {
+ const row = (now: number) => (
+ undefined} now={now} showOldest />
+ )
+ const { container, rerender } = render(row(NOW))
+ const age = () => container.querySelector(".juris-age")
+ expect(age()).toHaveTextContent("23h")
+ expect(age()).not.toHaveClass("overdue")
+
+ rerender(row(NOW + 2 * HOUR_MS))
+ expect(age()).toHaveTextContent("1d")
+ expect(age()).toHaveClass("overdue")
+ })
+})
diff --git a/apps/admin/src/features/discovery/jurisdiction-row.tsx b/apps/admin/src/features/discovery/jurisdiction-row.tsx
new file mode 100644
index 0000000..83065c2
--- /dev/null
+++ b/apps/admin/src/features/discovery/jurisdiction-row.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import type { JurisdictionDirectoryDTO } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation"
+import {
+ LAYER_LABEL,
+ UNMAPPED_GEOID,
+ dominantCategory,
+ formatRoutedDate,
+ formatWaitingAge,
+ isOverdue,
+} from "@/features/discovery/jurisdiction-view"
+import { RoutingStatusPill } from "@/features/discovery/routing-status-pill"
+import { categoryPinSrc } from "@/lib/category"
+import { formatCompactCount } from "@/lib/display"
+
+function MappedLeading({ item }: { item: JurisdictionDirectoryDTO }) {
+ const dominant = dominantCategory(item.perCategoryCounts)
+ const pin = dominant ? categoryPinSrc(dominant) : null
+ if (!pin) return
+ return
+}
+
+function MappedBadges({ item }: { item: JurisdictionDirectoryDTO }) {
+ return (
+ <>
+ {LAYER_LABEL[item.layer]}
+
+ {item.flaggedAt && (
+
+ Flagged
+
+ )}
+ {item.geoid}
+ >
+ )
+}
+
+function MappedSub({ item }: { item: JurisdictionDirectoryDTO }) {
+ return (
+ <>
+ {formatCompactCount(item.population)} pop
+ ·
+ {item.reportsWaiting} waiting
+ ·
+ {item.coverage}
+ >
+ )
+}
+
+function WaitingAge({ oldestReportAt, now }: { oldestReportAt: string | null; now: number }) {
+ const overdue = isOverdue(oldestReportAt, now)
+ return (
+
+ {overdue && }
+ {formatWaitingAge(oldestReportAt, now)}
+
+ )
+}
+
+function RowAge({
+ item,
+ unmapped,
+ showOldest,
+ now,
+}: {
+ item: JurisdictionDirectoryDTO
+ unmapped: boolean
+ showOldest: boolean
+ now: number
+}) {
+ if (showOldest) {
+ if (unmapped && !item.oldestReportAt) return null
+ return
+ }
+ if (unmapped) return null
+ return {formatRoutedDate(item.lastRouted)}
+}
+
+export const JurisdictionRow = React.memo(function JurisdictionRow({
+ item,
+ selected,
+ onSelect,
+ now,
+ showOldest = false,
+}: {
+ item: JurisdictionDirectoryDTO
+ selected: boolean
+ onSelect: (id: string) => void
+ now: number
+ showOldest?: boolean
+}) {
+ const unmapped = item.geoid === UNMAPPED_GEOID
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ if (!isKeyboardActivationKey(event.key)) return
+ event.preventDefault()
+ onSelect(item.geoid)
+ }
+
+ return (
+ onSelect(item.geoid)}
+ onKeyDown={onKeyDown}
+ >
+
+ {unmapped ? : }
+
+
+
+ {item.org}
+ {unmapped ? (
+
+
+ Needs mapping
+
+ ) : (
+
+ )}
+
+
+ {unmapped ? (
+ <>
+ {item.reportsWaiting} waiting
+ ·
+ location didn’t resolve to a jurisdiction
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+})
diff --git a/apps/admin/src/features/discovery/jurisdiction-view.test.ts b/apps/admin/src/features/discovery/jurisdiction-view.test.ts
new file mode 100644
index 0000000..f540374
--- /dev/null
+++ b/apps/admin/src/features/discovery/jurisdiction-view.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest"
+
+import { EMPTY_VALUE } from "@/lib/empty-value"
+
+import {
+ LAYER_LABEL,
+ dominantCategory,
+ formatRoutedDate,
+ formatWaitingAge,
+ isOverdue,
+} from "./jurisdiction-view"
+
+const NOW = Date.parse("2026-09-24T12:00:00.000Z")
+const MINUTE = 60_000
+const DAY = 24 * 60 * MINUTE
+
+function ago(ms: number): string {
+ return new Date(NOW - ms).toISOString()
+}
+
+describe("jurisdiction row formatting", () => {
+ it.each([
+ [30_000, "just now"],
+ [5 * MINUTE, "5m"],
+ [3 * 60 * MINUTE, "3h"],
+ [3 * DAY, "3d"],
+ [20 * DAY, "2w"],
+ [90 * DAY, "3mo"],
+ [800 * DAY, "2y"],
+ ])("ages a report waiting %i ms as %s", (elapsed, label) => {
+ expect(formatWaitingAge(ago(elapsed), NOW)).toBe(label)
+ })
+
+ it("shows the empty value for a missing or unreadable age", () => {
+ expect(formatWaitingAge(null, NOW)).toBe(EMPTY_VALUE)
+ expect(formatWaitingAge("not a date", NOW)).toBe(EMPTY_VALUE)
+ })
+
+ it("marks a report overdue after a day", () => {
+ expect(isOverdue(ago(DAY - MINUTE), NOW)).toBe(false)
+ expect(isOverdue(ago(DAY + MINUTE), NOW)).toBe(true)
+ expect(isOverdue(null, NOW)).toBe(false)
+ })
+
+ it("picks the category with the most waiting reports", () => {
+ expect(dominantCategory({ trash: 1, graffiti: 3 })).toBe("graffiti")
+ expect(dominantCategory({})).toBeNull()
+ })
+
+ it("labels every layer", () => {
+ expect(LAYER_LABEL).toEqual({
+ state: "State",
+ county: "County",
+ place: "City",
+ federal: "Federal land",
+ tribal: "Tribal",
+ })
+ })
+})
+
+describe("formatRoutedDate", () => {
+ it("shows the month and day", () => {
+ expect(formatRoutedDate("2026-09-01T12:00:00.000Z")).toBe("Sep 1")
+ })
+
+ it.each([null, "", "not a date"])("shows the placeholder for %j", (value) => {
+ expect(formatRoutedDate(value)).toBe(EMPTY_VALUE)
+ })
+})
diff --git a/apps/admin/src/features/discovery/jurisdiction-view.ts b/apps/admin/src/features/discovery/jurisdiction-view.ts
new file mode 100644
index 0000000..141c5ae
--- /dev/null
+++ b/apps/admin/src/features/discovery/jurisdiction-view.ts
@@ -0,0 +1,101 @@
+import {
+ relativeAgo,
+ type JurisdictionDirectoryDTO,
+ type JurisdictionLayer,
+ type PerCategoryCounts,
+ type ReportCategory,
+} from "@civfix/shared"
+
+import { REPORT_CATEGORIES, categoryLabel, categoryPinSrc, categoryReportTypes } from "@/lib/category"
+import { EMPTY_VALUE } from "@/lib/empty-value"
+import { MINUTE_MS } from "@/lib/timing"
+
+export const UNMAPPED_GEOID = "__unmapped__"
+
+export type LayerChoice = "all" | JurisdictionLayer
+
+export const LAYER_OPTIONS: readonly { value: JurisdictionLayer; label: string; plural: string }[] = [
+ { value: "state", label: "State", plural: "States" },
+ { value: "county", label: "County", plural: "Counties" },
+ { value: "place", label: "City", plural: "Cities" },
+ { value: "federal", label: "Federal land", plural: "Federal land" },
+ { value: "tribal", label: "Tribal", plural: "Tribal" },
+]
+
+export const LAYER_LABEL = Object.fromEntries(
+ LAYER_OPTIONS.map((option) => [option.value, option.label]),
+) as Record
+
+export const REPORT_TYPES: { id: ReportCategory; label: string; pin: string; types: string }[] =
+ REPORT_CATEGORIES.map((id) => ({
+ id,
+ label: categoryLabel(id),
+ pin: categoryPinSrc(id),
+ types: categoryReportTypes(id),
+ }))
+
+const HOUR_MS = 60 * MINUTE_MS
+const DAY_MS = 24 * HOUR_MS
+const DAYS_PER_WEEK = 7
+const DAYS_PER_MONTH = 30
+const DAYS_PER_YEAR = 365
+const MONTHS_PER_YEAR = 12
+const SHOW_WEEKS_FROM_DAYS = 14
+const SHOW_MONTHS_FROM_WEEKS = 8
+const OVERDUE_AFTER_MS = DAY_MS
+
+export function routingCount(counts: PerCategoryCounts, id: ReportCategory): number {
+ return counts[id] ?? 0
+}
+
+export function dominantCategory(counts: PerCategoryCounts): ReportCategory | null {
+ let best: ReportCategory | null = null
+ let bestCount = 0
+ for (const category of REPORT_CATEGORIES) {
+ const count = routingCount(counts, category)
+ if (count > bestCount) {
+ bestCount = count
+ best = category
+ }
+ }
+ return best
+}
+
+export function needsAttention(dto: JurisdictionDirectoryDTO): boolean {
+ return dto.reportsWaiting > 0 && dto.method === "none"
+}
+
+// en-US whatever the browser locale, unlike the shared date helpers: the Routed column has always read
+// this way.
+const ROUTED_DATE_FORMAT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" })
+
+export function formatRoutedDate(iso: string | null): string {
+ if (!iso || Number.isNaN(Date.parse(iso))) return EMPTY_VALUE
+ return ROUTED_DATE_FORMAT.format(new Date(iso))
+}
+
+// Reports can wait months, so past a week this keeps counting in days, then weeks, months and years
+// where the shared compact age would switch to an ever-growing week count.
+function longWaitingAge(days: number): string {
+ if (days < SHOW_WEEKS_FROM_DAYS) return `${days}d`
+ const weeks = Math.floor(days / DAYS_PER_WEEK)
+ if (weeks < SHOW_MONTHS_FROM_WEEKS) return `${weeks}w`
+ const months = Math.floor(days / DAYS_PER_MONTH)
+ if (months < MONTHS_PER_YEAR) return `${months}mo`
+ return `${Math.floor(days / DAYS_PER_YEAR)}y`
+}
+
+export function formatWaitingAge(iso: string | null, now = Date.now()): string {
+ if (!iso || Number.isNaN(Date.parse(iso))) return EMPTY_VALUE
+ return relativeAgo(iso, now, {
+ justNow: "just now",
+ absoluteFallback: (date) => longWaitingAge(Math.floor((now - date.getTime()) / DAY_MS)),
+ })
+}
+
+export function isOverdue(iso: string | null, now = Date.now()): boolean {
+ if (!iso) return false
+ const then = new Date(iso).getTime()
+ if (Number.isNaN(then)) return false
+ return now - then > OVERDUE_AFTER_MS
+}
diff --git a/apps/admin/src/features/discovery/routing-status-pill.tsx b/apps/admin/src/features/discovery/routing-status-pill.tsx
new file mode 100644
index 0000000..5f0cfce
--- /dev/null
+++ b/apps/admin/src/features/discovery/routing-status-pill.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import type { JurisdictionDirectoryDTO } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { needsAttention } from "@/features/discovery/jurisdiction-view"
+
+type PillPlacement = "row" | "head"
+
+const PLACEMENT = {
+ row: {
+ tight: " tight",
+ iconSize: 9,
+ bouncedTitle: "The routing contact hard-bounced",
+ style: undefined,
+ },
+ head: {
+ tight: "",
+ iconSize: 11,
+ bouncedTitle: "The routing contact hard-bounced. Re-enter a contact to clear it.",
+ style: { marginLeft: "auto" },
+ },
+} as const
+
+export function RoutingStatusPill({
+ dto,
+ placement,
+}: {
+ dto: JurisdictionDirectoryDTO
+ placement: PillPlacement
+}) {
+ const { tight, iconSize, bouncedTitle, style } = PLACEMENT[placement]
+ if (dto.status === "bounced") {
+ return (
+
+ Bounced
+
+ )
+ }
+ if (needsAttention(dto)) {
+ return (
+
+
+ Needs contact
+
+ )
+ }
+ return (
+
+ Routed
+
+ )
+}
diff --git a/apps/admin/src/features/discovery/unmapped-detail.tsx b/apps/admin/src/features/discovery/unmapped-detail.tsx
new file mode 100644
index 0000000..f565a6e
--- /dev/null
+++ b/apps/admin/src/features/discovery/unmapped-detail.tsx
@@ -0,0 +1,71 @@
+"use client"
+
+import type { JurisdictionDirectoryDTO } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { REPORT_TYPES, routingCount } from "@/features/discovery/jurisdiction-view"
+
+export function UnmappedDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
+ const counts = dto.perCategoryCounts
+ const waiting = REPORT_TYPES.filter((c) => routingCount(counts, c.id) > 0)
+ return (
+
+
+
+
+
+
+
Triage · location did not resolve
+
{dto.org}
+
+
+
+ {dto.reportsWaiting} waiting
+
+
+
+
+
What this is
+
+
+ These reports’ locations didn’t resolve to any jurisdiction on file, so they can’t be routed to
+ a city contact yet. This usually means jurisdiction boundaries haven’t been loaded (run the
+ jurisdictions seed/ingest), or the pins fall outside all known coverage.
+
+
+
+
+
+
Waiting reports by type
+
+ {waiting.length === 0 ? (
+
Nothing waiting.
+ ) : (
+
+ {waiting.map((c) => {
+ const n = routingCount(counts, c.id)
+ return (
+
+
+
+
+
+
{c.label}
+
+ {n} {n === 1 ? "report" : "reports"}
+
+
+
+ )
+ })}
+
+ )}
+
+ Once jurisdictions are loaded, new pins route automatically; existing unmapped pins may need a
+ re-resolve pass.
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/features/discovery/use-discovery.test.tsx b/apps/admin/src/features/discovery/use-discovery.test.tsx
new file mode 100644
index 0000000..0209c2c
--- /dev/null
+++ b/apps/admin/src/features/discovery/use-discovery.test.tsx
@@ -0,0 +1,62 @@
+import type { ReactNode } from "react"
+import { QueryClientProvider } from "@tanstack/react-query"
+import { act, renderHook } from "@testing-library/react"
+import { describe, expect, it, vi } from "vitest"
+
+import type * as ApiModule from "@/lib/api"
+import { queryKeys } from "@/lib/query"
+import { apiMock } from "@/test/api-mock"
+import { makeTestQueryClient } from "@/test/render"
+import {
+ useAddDiscoveryNote,
+ useFlagDiscovery,
+ usePatchJurisdiction,
+ useSaveDiscoveryDraft,
+ useSaveJurisdictionContacts,
+} from "@/features/discovery/use-discovery"
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const { apiMock } = await import("@/test/api-mock")
+ return { ...(await importOriginal()), api: apiMock }
+})
+
+const LIST = queryKeys.jurisdictions.list({ filter: "needs_mapping" })
+const BOUNDARY = queryKeys.jurisdictions.geometry("0644000")
+
+async function runWrite(
+ useHook: () => { mutateAsync: (input: I) => Promise },
+ input: I,
+) {
+ const client = makeTestQueryClient()
+ client.setQueryData(LIST, { pages: [], pageParams: [] })
+ client.setQueryData(BOUNDARY, { geoid: "0644000" })
+ const { result } = renderHook(useHook, {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ })
+ await act(() => result.current.mutateAsync(input))
+ return client
+}
+
+describe("discovery writes refresh the directory but never the boundary", () => {
+ it.each([
+ ["patch", "patchJurisdiction", () => usePatchJurisdiction(), {
+ request: { geoid: "0644000", flagged: true },
+ org: "Los Angeles",
+ action: "flag" as const,
+ }],
+ ["save & route", "saveJurisdictionContacts", () => useSaveJurisdictionContacts(), {
+ request: { geoid: "0644000", contacts: {} },
+ org: "Los Angeles",
+ }],
+ ["note", "addDiscoveryNote", () => useAddDiscoveryNote(), { id: "0644000", note: "Called" }],
+ ["flag", "flagDiscovery", () => useFlagDiscovery(), { id: "0644000", flagged: true }],
+ ["draft", "saveDiscoveryDraft", () => useSaveDiscoveryDraft(), { id: "0644000", contacts: {} }],
+ ] as const)("%s", async (_name, method, useHook, input) => {
+ apiMock[method].mockResolvedValue({ ok: true })
+ const client = await runWrite(useHook as never, input)
+ expect(client.getQueryState(LIST)?.isInvalidated).toBe(true)
+ expect(client.getQueryState(BOUNDARY)?.isInvalidated).toBe(false)
+ })
+})
diff --git a/apps/admin/src/features/discovery/use-discovery.ts b/apps/admin/src/features/discovery/use-discovery.ts
index a3ba073..6eadf06 100644
--- a/apps/admin/src/features/discovery/use-discovery.ts
+++ b/apps/admin/src/features/discovery/use-discovery.ts
@@ -1,34 +1,33 @@
"use client"
-import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import {
+ partialMatchKey,
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+ type QueryClient,
+} from "@tanstack/react-query"
import type {
AddNoteRequest,
DiscoveryListQuery,
DiscoveryListResponse,
FlagDiscoveryRequest,
GetDiscoveryTaskResponse,
- JurisdictionDirectoryResponse,
JurisdictionGeometryResponse,
- JurisdictionListQuery,
PatchJurisdictionRequest,
SaveContactsRequest,
SaveDraftRequest,
} from "@civfix/shared"
import { api } from "@/lib/api"
-import { queryKeys } from "@/lib/query"
+import { errorMessage } from "@/lib/error-messages"
+import { infiniteListOptions } from "@/lib/infinite"
+import { invalidateKeys, queryKeys } from "@/lib/query"
+import { MINUTE_MS } from "@/lib/timing"
+import { partialSaveMessage, type SavedExtras } from "@/features/discovery/discovery-payloads"
+import type { DirectoryQuery } from "@/features/discovery/discovery-ui-state"
-/**
- * Data hooks for the Discovery / Jurisdictions section (enumeration 2.B). Reads use GET /admin/discovery
- * (list) and GET /admin/discovery/:id (detail); writes use the discovery + jurisdictions mutations. All
- * mutations invalidate the discovery + jurisdictions caches plus the cross-cutting home summary
- * (a saved contact / flag changes the dashboard aggregates), matching the scaffold's documented pattern.
- *
- * Query keys: reuses the existing registry in src/lib/query.ts (discovery.list/detail/all,
- * jurisdictions.all). No local keys were needed.
- */
-
-/** GET /admin/discovery - the population-sorted discovery queue (filter/sort/search via params). */
export function useDiscoveryList(params: DiscoveryListQuery) {
return useQuery({
queryKey: queryKeys.discovery.list(params),
@@ -36,120 +35,145 @@ export function useDiscoveryList(params: DiscoveryListQuery) {
})
}
-/** GET /admin/discovery/:id - full task (notes, per-category counts, existing contacts, geometry). */
-export function useDiscoveryTask(id: string | null) {
- return useQuery({
- queryKey: queryKeys.discovery.detail(id ?? ""),
- queryFn: () => api.getDiscoveryTask({ id: id as string }),
- enabled: !!id,
- })
-}
-
-/** The server-driven directory query: search (`q`) + routing-posture `filter` + type `layer` + `sort`. `cursor`/`limit` are paged internally. */
-export type JurisdictionDirectoryParams = Pick
-
-/** Page size for the directory infinite scroll (the wire caps at 100; 50 keeps each page snappy). */
+/** The wire caps a page at 100; 50 keeps each scroll step quick. */
const DIRECTORY_PAGE_SIZE = 50
+const GEOMETRY_STALE_MS = 5 * MINUTE_MS
+
/**
- * GET /admin/jurisdictions - the full jurisdiction directory: EVERY jurisdiction reports map to (incl.
- * federal land), with its type, routing posture, waiting-report counts, and existing contacts.
- *
- * Server-driven: search/filter/sort happen in Postgres and the page scrolls via `nextCursor`, so the
- * operator can reach ALL ~28k jurisdictions (the prior client-only `limit:100` showed only the first
- * page, alphabetically Alabama). `total` + `facets` ride along on the first page for the header + chips.
+ * Search, filter and sort run in Postgres and the list pages by cursor, so the operator can reach all
+ * ~28k jurisdictions, federal land included. `total` and `facets` ride on the first page only.
*/
-export function useJurisdictionDirectory(params: JurisdictionDirectoryParams) {
- return useInfiniteQuery({
- queryKey: queryKeys.jurisdictions.list(params),
- queryFn: ({ pageParam }) =>
- api.listJurisdictions({
- ...params,
- limit: DIRECTORY_PAGE_SIZE,
- ...(typeof pageParam === "string" ? { cursor: pageParam } : {}),
- }),
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
- })
+export function useJurisdictionListInfinite(params: DirectoryQuery) {
+ return useInfiniteQuery(
+ infiniteListOptions(
+ queryKeys.jurisdictions.list(params),
+ { ...params, limit: DIRECTORY_PAGE_SIZE },
+ (input) => api.listJurisdictions(input),
+ ),
+ )
}
/**
- * GET /admin/jurisdictions/:geoid/geometry - one jurisdiction's simplified boundary (GeoJSON + bbox +
- * interior point) for the directory's verification map. Disabled for the synthetic "Unmapped" row and
- * until a real geoid is selected. A 404 (no stored boundary) surfaces as the query error, and the detail
- * panel falls back to the text label.
+ * Not retried: a 404 means the jurisdiction has no stored boundary, and the detail panel falls back to
+ * the text label on the query error.
*/
export function useJurisdictionGeometry(geoid: string | null) {
return useQuery({
queryKey: queryKeys.jurisdictions.geometry(geoid ?? ""),
queryFn: () => api.getJurisdictionGeometry({ geoid: geoid as string }),
enabled: !!geoid,
- staleTime: 5 * 60 * 1000,
+ staleTime: GEOMETRY_STALE_MS,
retry: false,
})
}
-/** Invalidate every discovery/jurisdiction view plus the home aggregates after a write. */
-function invalidateDiscovery(qc: ReturnType) {
- qc.invalidateQueries({ queryKey: queryKeys.discovery.all })
- qc.invalidateQueries({ queryKey: queryKeys.jurisdictions.all })
- qc.invalidateQueries({ queryKey: queryKeys.home.all })
+const BOUNDARY_KEY = queryKeys.jurisdictions.geometry("").slice(0, -1)
+
+/** The home aggregates count saved contacts and flags, so every discovery write refreshes them too. */
+function invalidateDiscovery(qc: QueryClient, id?: string) {
+ return invalidateKeys(qc, [
+ id ? queryKeys.discovery.detail(id) : null,
+ queryKeys.discovery.all,
+ // No discovery write changes a boundary, and refetching the polygon would hold every save's
+ // pending state on the largest payload in the section.
+ {
+ queryKey: queryKeys.jurisdictions.all,
+ predicate: (query) => !partialMatchKey(query.queryKey, BOUNDARY_KEY),
+ },
+ queryKeys.home.all,
+ ])
+}
+
+// Unused until the discovery notes decision (append-only task notes vs the jurisdiction note field):
+// it reads the discovery task that owns the note history.
+/** @public */
+export function useDiscoveryTask(id: string | null) {
+ return useQuery({
+ queryKey: queryKeys.discovery.detail(id ?? ""),
+ queryFn: () => api.getDiscoveryTask({ id: id as string }),
+ enabled: !!id,
+ })
}
-/** POST /admin/discovery/:id/notes - append an operator note to a discovery task. */
export function useAddDiscoveryNote() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: AddNoteRequest) => api.addDiscoveryNote(input),
- onSuccess: (_res, { id }) => {
- qc.invalidateQueries({ queryKey: queryKeys.discovery.detail(id) })
- invalidateDiscovery(qc)
- },
+ onSuccess: (_res, { id }) => invalidateDiscovery(qc, id),
})
}
-/** POST /admin/discovery/:id/flag - flag a discovery task / jurisdiction for review. */
export function useFlagDiscovery() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: FlagDiscoveryRequest) => api.flagDiscovery(input),
- onSuccess: (_res, { id }) => {
- qc.invalidateQueries({ queryKey: queryKeys.discovery.detail(id) })
- invalidateDiscovery(qc)
- },
+ onSuccess: (_res, { id }) => invalidateDiscovery(qc, id),
})
}
-/** POST /admin/discovery/:id/draft - save the routing-contact draft without routing. */
export function useSaveDiscoveryDraft() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: SaveDraftRequest) => api.saveDiscoveryDraft(input),
- onSuccess: (_res, { id }) => {
- qc.invalidateQueries({ queryKey: queryKeys.discovery.detail(id) })
- invalidateDiscovery(qc)
- },
+ onSuccess: (_res, { id }) => invalidateDiscovery(qc, id),
})
}
-/** POST /admin/jurisdictions/:geoid/contacts - the core "Save contacts" action. */
+export interface SaveContactsVariables {
+ request: SaveContactsRequest
+ /** The jurisdiction's name, for the success toast. */
+ org: string
+ /** The note / @handle a PATCH already saved just before this request, which a failure must mention. */
+ savedFirst?: SavedExtras
+}
+
export function useSaveJurisdictionContacts() {
const qc = useQueryClient()
return useMutation({
- mutationFn: (input: SaveContactsRequest) => api.saveJurisdictionContacts(input),
+ mutationFn: ({ request }: SaveContactsVariables) => api.saveJurisdictionContacts(request),
onSuccess: () => invalidateDiscovery(qc),
+ meta: {
+ errorMessage: (error: unknown, { savedFirst }: SaveContactsVariables) =>
+ savedFirst ? partialSaveMessage(savedFirst, errorMessage(error)) : errorMessage(error),
+ successMessage: (_res: unknown, { org }: SaveContactsVariables) =>
+ `Contacts saved for ${org} · discovery task closed`,
+ },
})
}
-/**
- * PATCH /admin/jurisdictions/:geoid - non-routing edits on a directory jurisdiction: save a contact
- * draft (contacts/form without routing the pending pins) and flag / unflag for review (flagged +
- * flagReason). Used by the Directory detail's "Save draft" and "Flag for review" actions.
- */
+export interface PatchJurisdictionVariables {
+ request: PatchJurisdictionRequest
+ /** The jurisdiction's name, for the success toast. */
+ org: string
+ /**
+ * What the operator saved, which picks the toast. "extras" is the note / @handle saved just before
+ * Save & route, which leaves the toast to that save.
+ */
+ action: "flag" | "draft" | "template" | "extras"
+}
+
+function patchSuccessMessage({ request, org, action }: PatchJurisdictionVariables): string | null {
+ switch (action) {
+ case "flag":
+ return request.flagged ? `${org} flagged for review` : `Flag cleared for ${org}`
+ case "draft":
+ return `Draft saved for ${org}`
+ case "template":
+ return `Email template saved for ${org}`
+ case "extras":
+ return null
+ }
+}
+
export function usePatchJurisdiction() {
const qc = useQueryClient()
return useMutation({
- mutationFn: (input: PatchJurisdictionRequest) => api.patchJurisdiction(input),
+ mutationFn: ({ request }: PatchJurisdictionVariables) => api.patchJurisdiction(request),
onSuccess: () => invalidateDiscovery(qc),
+ meta: {
+ successMessage: (_res: unknown, variables: PatchJurisdictionVariables) =>
+ patchSuccessMessage(variables),
+ },
})
}
diff --git a/apps/admin/src/features/discovery/use-jurisdiction-editor.ts b/apps/admin/src/features/discovery/use-jurisdiction-editor.ts
new file mode 100644
index 0000000..7f7d9a9
--- /dev/null
+++ b/apps/admin/src/features/discovery/use-jurisdiction-editor.ts
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import type { JurisdictionDirectoryDTO } from "@civfix/shared"
+
+import { promptDialog } from "@/components/shared/dialog"
+import {
+ afterContactsSaved,
+ contactsPayload,
+ jurisdictionFields,
+ noteAndHandleFields,
+ parseHandle,
+ routingContactsPayload,
+ withContactEdit,
+ type ContactEdits,
+} from "@/features/discovery/discovery-payloads"
+import { REPORT_TYPES, routingCount } from "@/features/discovery/jurisdiction-view"
+import { usePatchJurisdiction, useSaveJurisdictionContacts } from "@/features/discovery/use-discovery"
+import { useToast } from "@/store/ui-store"
+
+function seedContacts(dto: JurisdictionDirectoryDTO): Record {
+ const seed: Record = {}
+ for (const contact of dto.contacts) {
+ if (contact.email) seed[contact.category] = contact.email
+ }
+ return seed
+}
+
+function isEmpty(fields: object): boolean {
+ return Object.keys(fields).length === 0
+}
+
+function useJurisdictionDraft(dto: JurisdictionDirectoryDTO) {
+ const [contacts, setContacts] = React.useState>(() => seedContacts(dto))
+ const [contactEdits, setContactEdits] = React.useState({})
+ const [note, setNote] = React.useState("")
+ const [defaultEmail, setDefaultEmail] = React.useState(dto.email ?? "")
+ const [formUrl, setFormUrl] = React.useState(dto.form ?? "")
+ const [handle, setHandle] = React.useState(dto.handle ?? "")
+
+ const setCategoryContact = (category: string, email: string) => {
+ setContacts((prev) => ({ ...prev, [category]: email }))
+ setContactEdits((prev) => withContactEdit(prev, category))
+ }
+ const contactsSaved = (sent: ContactEdits) =>
+ setContactEdits((prev) => afterContactsSaved(prev, sent))
+
+ const hasDefault = defaultEmail.trim() !== ""
+ const filledCount = REPORT_TYPES.filter((c) => contacts[c.id]).length
+ return {
+ contacts,
+ contactEdits,
+ setCategoryContact,
+ contactsSaved,
+ note,
+ setNote,
+ defaultEmail,
+ setDefaultEmail,
+ formUrl,
+ setFormUrl,
+ handle,
+ setHandle,
+ parsedHandle: parseHandle(handle),
+ hasDefault,
+ filledCount,
+ missingCount: REPORT_TYPES.filter(
+ (c) => routingCount(dto.perCategoryCounts, c.id) > 0 && !contacts[c.id] && !hasDefault,
+ ).length,
+ canSave: filledCount > 0 || hasDefault,
+ }
+}
+
+export function useJurisdictionEditor(dto: JurisdictionDirectoryDTO) {
+ const toast = useToast()
+ const patch = usePatchJurisdiction()
+ const saveContacts = useSaveJurisdictionContacts()
+ const draft = useJurisdictionDraft(dto)
+ const { contacts, contactEdits, contactsSaved, note, defaultEmail, formUrl, parsedHandle, canSave } =
+ draft
+ const isFlagged = dto.flaggedAt !== null
+ const saveBlocked = saveContacts.isPending || patch.isPending || parsedHandle.error !== null
+
+ const toggleFlag = async () => {
+ if (isFlagged) {
+ patch.mutate({ request: { geoid: dto.geoid, flagged: false }, org: dto.org, action: "flag" })
+ return
+ }
+ const reason = await promptDialog({ title: "Flag jurisdiction", label: "Reason (optional)" })
+ if (reason === null) return
+ patch.mutate({
+ request: { geoid: dto.geoid, flagged: true, flagReason: reason || undefined },
+ org: dto.org,
+ action: "flag",
+ })
+ }
+
+ const saveDraft = () => {
+ const sentEdits = contactEdits
+ const contactFields = contactsPayload(sentEdits, contacts)
+ const fields = jurisdictionFields(defaultEmail, formUrl)
+ const extras = noteAndHandleFields(note, parsedHandle.value, dto.handle)
+ if (isEmpty(contactFields) && isEmpty(fields) && isEmpty(extras)) {
+ toast("Nothing to save yet")
+ return
+ }
+ patch.mutate(
+ {
+ request: { geoid: dto.geoid, contacts: contactFields, ...fields, ...extras },
+ org: dto.org,
+ action: "draft",
+ },
+ { onSuccess: () => contactsSaved(sentEdits) },
+ )
+ }
+
+ const saveAndRoute = async () => {
+ if (!canSave) return
+ const extras = noteAndHandleFields(note, parsedHandle.value, dto.handle)
+ const savesExtrasFirst = !isEmpty(extras)
+ const sentEdits = contactEdits
+ const saveRoutingContacts = () =>
+ saveContacts.mutate(
+ {
+ request: {
+ geoid: dto.geoid,
+ contacts: routingContactsPayload(sentEdits, contacts),
+ ...jurisdictionFields(defaultEmail, formUrl),
+ },
+ org: dto.org,
+ ...(savesExtrasFirst ? { savedFirst: extras } : {}),
+ },
+ { onSuccess: () => contactsSaved(sentEdits) },
+ )
+ if (!savesExtrasFirst) {
+ saveRoutingContacts()
+ return
+ }
+ // Awaited rather than chained through a per-call callback: that is dropped once this pane unmounts,
+ // which would save the note but never the contacts.
+ try {
+ await patch.mutateAsync({ request: { geoid: dto.geoid, ...extras }, org: dto.org, action: "extras" })
+ } catch {
+ // The query client already toasted the failure, and the contacts must not save without the note.
+ return
+ }
+ saveRoutingContacts()
+ }
+
+ return { ...draft, patch, isFlagged, saveBlocked, toggleFlag, saveDraft, saveAndRoute }
+}
diff --git a/apps/admin/src/features/events/event-attendee-messages.tsx b/apps/admin/src/features/events/event-attendee-messages.tsx
new file mode 100644
index 0000000..0860a9d
--- /dev/null
+++ b/apps/admin/src/features/events/event-attendee-messages.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import * as React from "react"
+import { PostMessageRequestSchema, type AdminEventDTO } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { usePostEventMessage } from "@/features/events/use-events"
+import { pluralize } from "@/features/reports/plural"
+import { SubmitShortcutHint, SUBMIT_KEYSHORTCUTS } from "@/features/reports/submit-shortcut"
+
+const ATTENDEE_MESSAGE_MAX = PostMessageRequestSchema.shape.body.maxLength ?? undefined
+const ATTENDEE_UPDATE_ROWS = 2
+
+function SentMessages({ messages }: { messages: AdminEventDTO["messages"] }) {
+ return (
+
+ {messages.map((m, i) => (
+
+ {m.who}
+ {m.text}
+ {m.when}
+
+ ))}
+
+ )
+}
+
+export function useAttendeeUpdate() {
+ const postMutation = usePostEventMessage()
+ const [updateDraft, setUpdateDraft] = React.useState("")
+ return { postMutation, updateDraft, setUpdateDraft }
+}
+
+export function EventAttendeeMessages({
+ event,
+ update,
+}: {
+ event: AdminEventDTO
+ update: ReturnType
+}) {
+ const { postMutation, updateDraft, setUpdateDraft } = update
+ const noAttendees = event.attendees === 0
+ const blocked = noAttendees || !updateDraft.trim()
+
+ const send = () => {
+ const body = updateDraft.trim()
+ if (!body || postMutation.isPending) return
+ postMutation.mutate({ id: event.id, body }, { onSuccess: () => setUpdateDraft("") })
+ }
+
+ return (
+
+
Message attendees
+
+ {event.messages.length > 0 &&
}
+
+
+ )
+}
diff --git a/apps/admin/src/features/events/event-detail-drafts.test.tsx b/apps/admin/src/features/events/event-detail-drafts.test.tsx
new file mode 100644
index 0000000..6905c0d
--- /dev/null
+++ b/apps/admin/src/features/events/event-detail-drafts.test.tsx
@@ -0,0 +1,74 @@
+import { act, screen, within } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import type { AdminEventDTO, AdminEventListItemDTO, AdminEventListResponse } from "@civfix/shared"
+import { describe, expect, it, vi } from "vitest"
+
+import type * as ApiModule from "@/lib/api"
+import { queryKeys } from "@/lib/query"
+import { apiMock } from "@/test/api-mock"
+import { renderWithQuery } from "@/test/render"
+import { detailCard } from "@/test/panes"
+import { EventsPage } from "@/features/events/events-page"
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const { apiMock } = await import("@/test/api-mock")
+ return { ...(await importOriginal()), api: apiMock }
+})
+
+vi.mock("@/components/map/leaflet-map", () => ({
+ LeafletMap: () => ,
+}))
+
+const BEACH = {
+ id: "aaaa1111-0000-4000-8000-000000000001",
+ title: "Beach sweep",
+ status: "in_progress",
+ eventKind: "cleanup",
+ flagged: false,
+ place: "Ocean Beach",
+ attendees: 14,
+ capacity: 20,
+ bags: 0,
+ organizer: { id: "u-org", name: "Olive Park", handle: "@olive", joined: "Mar 2026" },
+ date: { rel: "today", abs: "Sep 24, 9:00 AM" },
+ coords: [37.8, -122.26],
+} satisfies AdminEventListItemDTO
+
+const BEACH_DETAIL = {
+ ...BEACH,
+ desc: "About the beach sweep",
+ address: "Ocean Beach main entrance",
+ timeline: [],
+ messages: [],
+ linkedReports: [],
+} satisfies AdminEventDTO
+
+const PAGE = {
+ items: [BEACH],
+ nextCursor: null,
+ counts: { all: 1, upcoming: 0, in_progress: 1, completed: 0, flagged: 0 },
+} satisfies AdminEventListResponse
+
+describe("EventDetail drafts", () => {
+ it("keeps the attendee update and bags drafts through a failed detail refetch and its retry", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(PAGE)
+ apiMock.getAdminEvent.mockResolvedValue(BEACH_DETAIL)
+ const user = userEvent.setup()
+ const { client } = renderWithQuery()
+
+ const update = await screen.findByRole("textbox", { name: "Update for attendees" })
+ await user.type(update, "Meet at the north lot")
+ await user.type(screen.getByRole("spinbutton", { name: "Bags collected" }), "12")
+
+ apiMock.getAdminEvent.mockRejectedValueOnce(new Error("Detail refetch failed"))
+ await act(() => client.refetchQueries({ queryKey: queryKeys.events.detail(BEACH.id) }))
+ const retry = await within(detailCard()).findByRole("button", { name: "Try again" })
+ expect(screen.queryByRole("textbox", { name: "Update for attendees" })).toBeNull()
+
+ await user.click(retry)
+ expect(await screen.findByRole("textbox", { name: "Update for attendees" })).toHaveValue(
+ "Meet at the north lot",
+ )
+ expect(screen.getByRole("spinbutton", { name: "Bags collected" })).toHaveValue(12)
+ })
+})
diff --git a/apps/admin/src/features/events/event-detail.tsx b/apps/admin/src/features/events/event-detail.tsx
new file mode 100644
index 0000000..c7016f4
--- /dev/null
+++ b/apps/admin/src/features/events/event-detail.tsx
@@ -0,0 +1,298 @@
+"use client"
+
+import * as React from "react"
+import dynamic from "next/dynamic"
+import type { AdminEventDTO } from "@civfix/shared"
+
+import { Icons, type IconComponent } from "@/components/icons"
+import { EmptyState } from "@/components/shared/page-primitives"
+import { LoadingState, ErrorState } from "@/components/shared/data-states"
+import { confirmDialog } from "@/components/shared/dialog"
+import { shortRef } from "@/lib/display"
+import { cancelBlockedMessage, eventStatusView } from "@/lib/event-status"
+import { eventKindView, EVENT_KIND_PIN_KIND } from "@/lib/event-kind"
+import { EventAttendeeMessages, useAttendeeUpdate } from "@/features/events/event-attendee-messages"
+import { EventLinkedReports } from "@/features/events/event-linked-reports"
+import { EventOrganizerCard } from "@/features/events/event-organizer-card"
+import { EventTurnoutCard, useOutcomeDraft } from "@/features/events/event-turnout-card"
+import { LinkReportsPicker } from "@/features/events/link-reports-picker"
+import {
+ useCancelEvent,
+ useEvent,
+ useFlagEvent,
+ useLinkReports,
+ useUnlinkReport,
+} from "@/features/events/use-events"
+
+const LeafletMap = dynamic(() => import("@/components/map/leaflet-map").then((m) => m.LeafletMap), {
+ ssr: false,
+ loading: () => ,
+})
+
+const EVENT_MINIMAP_ZOOM = 13
+const MINIMAP_PIN_ID = "e"
+
+type EventTimelineItem = AdminEventDTO["timeline"][number]
+
+const TIMELINE_ICON: Record = {
+ create: Icons.Pin,
+ status: Icons.Clock,
+ join: Icons.Users,
+ message: Icons.Mail,
+ done: Icons.Check,
+ warn: Icons.AlertTriangle,
+ cancel: Icons.Trash,
+ linked: Icons.Layers,
+ unlinked: Icons.X,
+}
+
+function EventHead({ event }: { event: AdminEventDTO }) {
+ const statusPill = eventStatusView(event.status)
+ return (
+
+
+
+
+
+
+
+
+ {shortRef(event.id)} · {eventKindView(event.eventKind).label}
+
+
{event.title}
+
+ {event.flagged && (
+
+ Flagged
+
+ )}
+
+ {statusPill.label}
+
+
+ )
+}
+
+function EventAbout({ event }: { event: AdminEventDTO }) {
+ return (
+
+
+ About
+
+ {event.attendees} attending
+
+
+
+
{event.desc}
+
+
+ {event.address}
+
+ ·
+
+ {event.date.abs}
+
+
+
+
+ )
+}
+
+function EventMeetLocation({ event }: { event: AdminEventDTO }) {
+ return (
+
+ )
+}
+
+function TimelineRow({ item }: { item: EventTimelineItem }) {
+ const TimelineIcon = TIMELINE_ICON[item.kind] ?? Icons.Clock
+ return (
+
+
+
+
+
+
+ {item.who} {item.what}
+
+
{item.when}
+
+
+ )
+}
+
+function EventActivity({ timeline }: { timeline: EventTimelineItem[] }) {
+ return (
+
+
Activity
+
+ {timeline.length === 0 ? (
+
} />
+ ) : (
+
+ {timeline.map((t, i) => (
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+function useEventModeration() {
+ const flagMutation = useFlagEvent()
+ const cancelMutation = useCancelEvent()
+ return { flagMutation, cancelMutation }
+}
+
+function EventModerateBar({
+ event,
+ moderation,
+}: {
+ event: AdminEventDTO
+ moderation: ReturnType
+}) {
+ const { flagMutation, cancelMutation } = moderation
+ const cancelBlockedId = React.useId()
+ const cancelBlockedReason = cancelBlockedMessage(event.status)
+
+ const onCancel = async () => {
+ const ok = await confirmDialog({
+ title: "Cancel this event?",
+ body: "Attendees will be notified that the event is cancelled. This can't be undone.",
+ danger: true,
+ confirmLabel: "Cancel event",
+ cancelLabel: "Keep event",
+ })
+ if (!ok) return
+ cancelMutation.mutate({ id: event.id })
+ }
+
+ return (
+ <>
+
+
Moderate
+
+
+
+
+ {cancelBlockedReason && (
+
+ {cancelBlockedReason}
+
+ )}
+ >
+ )
+}
+
+export function EventDetail({ eventId }: { eventId: string }) {
+ const eventQuery = useEvent(eventId)
+ // Drafts and mutations live above the loading and error returns: a failed detail refetch must not
+ // clear a typed update or re-enable a button whose request is still in flight.
+ const moderation = useEventModeration()
+ const update = useAttendeeUpdate()
+ const outcome = useOutcomeDraft()
+ const linkReports = useLinkReports()
+ const unlinkMutation = useUnlinkReport()
+ const [pickerOpen, setPickerOpen] = React.useState(false)
+
+ if (eventQuery.isLoading) return
+ if (eventQuery.isError) {
+ return eventQuery.refetch()} />
+ }
+ const event = eventQuery.data
+ if (!event) {
+ return (
+ }
+ />
+ )
+ }
+
+ const isCleanup = event.eventKind === "cleanup"
+
+ const onLink = (reportIds: string[]) => {
+ linkReports.mutate({ id: event.id, reportIds }, { onSuccess: () => setPickerOpen(false) })
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {isCleanup && (
+ setPickerOpen(true)}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {pickerOpen && isCleanup && (
+
r.id))}
+ pending={linkReports.isPending}
+ onClose={() => setPickerOpen(false)}
+ onLink={onLink}
+ />
+ )}
+
+ )
+}
diff --git a/apps/admin/src/features/events/event-linked-reports.tsx b/apps/admin/src/features/events/event-linked-reports.tsx
new file mode 100644
index 0000000..b745103
--- /dev/null
+++ b/apps/admin/src/features/events/event-linked-reports.tsx
@@ -0,0 +1,119 @@
+"use client"
+
+import { REPORT_CATEGORY_LABELS, type AdminEventDTO, type LinkedReportRef } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { EmptyState } from "@/components/shared/page-primitives"
+import { confirmDialog } from "@/components/shared/dialog"
+import { categoryPinSrc } from "@/lib/category"
+import { reportStatusView } from "@/lib/report-status"
+import type { useUnlinkReport } from "@/features/events/use-events"
+import { useNav } from "@/store/ui-store"
+
+function LinkedReportCard({
+ report,
+ onOpen,
+ onUnlink,
+ unlinking,
+}: {
+ report: LinkedReportRef
+ onOpen: () => void
+ onUnlink?: () => void
+ unlinking?: boolean
+}) {
+ const view = reportStatusView(report.status)
+ const card = (
+
+ )
+ if (!onUnlink) return card
+ return (
+
+ {card}
+
+
+ )
+}
+
+export function EventLinkedReports({
+ event,
+ linking,
+ unlinkMutation,
+ onOpenPicker,
+}: {
+ event: AdminEventDTO
+ linking: boolean
+ unlinkMutation: ReturnType
+ onOpenPicker: () => void
+}) {
+ const nav = useNav()
+
+ const onUnlink = async (report: LinkedReportRef) => {
+ const ok = await confirmDialog({
+ title: "Unlink report",
+ body: `This unlinks "${report.title}" from this event. The report itself is untouched.`,
+ danger: true,
+ confirmLabel: "Unlink",
+ })
+ if (!ok) return
+ unlinkMutation.mutate({ id: event.id, reportId: report.id })
+ }
+
+ return (
+
+
+ Linked reports
+
+
+ {event.linkedReports.length}
+
+
+
+ {event.linkedReports.length === 0 ? (
+
}
+ />
+ ) : (
+
+ {event.linkedReports.map((r) => (
+ nav("reports", r.id)}
+ onUnlink={() => void onUnlink(r)}
+ unlinking={unlinkMutation.isPending}
+ />
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/apps/admin/src/features/events/event-list-pane.tsx b/apps/admin/src/features/events/event-list-pane.tsx
new file mode 100644
index 0000000..4b86283
--- /dev/null
+++ b/apps/admin/src/features/events/event-list-pane.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import type { AdminEventListItemDTO } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { EmptyState } from "@/components/shared/page-primitives"
+import { ListCard, ListStates, LoadMoreButton } from "@/components/shared/section-list"
+import { EventRow } from "@/features/events/event-row"
+import type { useEventListInfinite } from "@/features/events/use-events"
+
+export function EventListPane({
+ listQuery,
+ items,
+ listCount,
+ selId,
+ onSelect,
+}: {
+ listQuery: ReturnType
+ items: AdminEventListItemDTO[]
+ listCount: number
+ selId: string | null
+ onSelect: (id: string) => void
+}) {
+ return (
+
+ }
+ />
+ }
+ >
+ {items.map((e) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/admin/src/features/events/event-organizer-card.tsx b/apps/admin/src/features/events/event-organizer-card.tsx
new file mode 100644
index 0000000..a119c2c
--- /dev/null
+++ b/apps/admin/src/features/events/event-organizer-card.tsx
@@ -0,0 +1,43 @@
+"use client"
+
+import type { AdminEventDTO } from "@civfix/shared"
+
+import { initials } from "@/lib/display"
+import { useNav } from "@/store/ui-store"
+
+// The server sends "-" when the organizer's join date is unknown.
+const UNKNOWN_JOIN_DATE = "-"
+
+export function EventOrganizerCard({ organizer }: { organizer: AdminEventDTO["organizer"] }) {
+ const nav = useNav()
+ return (
+
+
Organizer
+
+
+
+ {initials(organizer.name)}
+
+
+
{organizer.name}
+
{organizer.handle}
+
+
+ {organizer.joined !== UNKNOWN_JOIN_DATE && (
+
+
+ Joined
+ {organizer.joined}
+
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/admin/src/features/events/event-outcome.test.ts b/apps/admin/src/features/events/event-outcome.test.ts
new file mode 100644
index 0000000..6079b65
--- /dev/null
+++ b/apps/admin/src/features/events/event-outcome.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest"
+
+import { parseBags } from "./event-outcome"
+
+describe("parseBags", () => {
+ it.each([
+ ["", null],
+ [" ", null],
+ ["-1", null],
+ ["0", 0],
+ ["12", 12],
+ ["2.5", null],
+ ["1e3", 1000],
+ ["100000", 100000],
+ ["100001", null],
+ ["abc", null],
+ ])("parses %j as %j by the contract's rule", (input, expected) => {
+ expect(parseBags(input)).toBe(expected)
+ })
+})
diff --git a/apps/admin/src/features/events/event-outcome.ts b/apps/admin/src/features/events/event-outcome.ts
new file mode 100644
index 0000000..5750737
--- /dev/null
+++ b/apps/admin/src/features/events/event-outcome.ts
@@ -0,0 +1,7 @@
+import { SetEventOutcomeRequestSchema } from "@civfix/shared"
+
+export function parseBags(input: string): number | null {
+ if (input.trim() === "") return null
+ const parsed = SetEventOutcomeRequestSchema.shape.bags.safeParse(Number(input))
+ return parsed.success ? parsed.data : null
+}
diff --git a/apps/admin/src/features/events/event-row.tsx b/apps/admin/src/features/events/event-row.tsx
new file mode 100644
index 0000000..b7b3e0d
--- /dev/null
+++ b/apps/admin/src/features/events/event-row.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import * as React from "react"
+import type { AdminEventListItemDTO } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation"
+import { eventStatusView } from "@/lib/event-status"
+import { eventKindView } from "@/lib/event-kind"
+import { firstName, shortRef } from "@/lib/display"
+import { useNav } from "@/store/ui-store"
+
+function OrganizerLink({ organizer }: { organizer: AdminEventListItemDTO["organizer"] }) {
+ const nav = useNav()
+ return (
+
+ )
+}
+
+export const EventRow = React.memo(function EventRow({
+ item,
+ selected,
+ onSelect,
+}: {
+ item: AdminEventListItemDTO
+ selected: boolean
+ onSelect: (id: string) => void
+}) {
+ const kindView = eventKindView(item.eventKind)
+ const KindIcon = kindView.icon
+ const statusPill = eventStatusView(item.status)
+ return (
+ onSelect(item.id)}
+ onKeyDown={(e) => {
+ // A key pressed on the nested organizer link bubbles here; it must stay that link's activation.
+ if (e.target !== e.currentTarget || !isKeyboardActivationKey(e.key)) return
+ e.preventDefault()
+ onSelect(item.id)
+ }}
+ >
+
+
+
+
+
+
+
+ {item.title}
+ {item.flagged && (
+
+
+
+ )}
+
+ {shortRef(item.id)}
+
+
+
+ {item.place}
+ ·
+ {item.attendees} attending
+ ·
+
+
+
+
+ {statusPill.label}
+ {item.date.abs}
+
+
+ )
+})
diff --git a/apps/admin/src/features/events/event-status-fallback.test.tsx b/apps/admin/src/features/events/event-status-fallback.test.tsx
new file mode 100644
index 0000000..eaada3f
--- /dev/null
+++ b/apps/admin/src/features/events/event-status-fallback.test.tsx
@@ -0,0 +1,88 @@
+import { screen, within } from "@testing-library/react"
+import type {
+ AdminEventDTO,
+ AdminEventListItemDTO,
+ AdminEventListResponse,
+ AdminOrgDTO,
+ AdminOrgEventListResponse,
+ EventStatus,
+} from "@civfix/shared"
+import { describe, expect, it, vi } from "vitest"
+
+import type * as ApiModule from "@/lib/api"
+import { apiMock } from "@/test/api-mock"
+import { renderWithQuery } from "@/test/render"
+import { detailCard, listCard, queueRowOf } from "@/test/panes"
+import { EventsPage } from "@/features/events/events-page"
+import { OrgEventsPanel } from "@/features/orgs/org-events-panel"
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const { apiMock } = await import("@/test/api-mock")
+ return { ...(await importOriginal()), api: apiMock }
+})
+
+vi.mock("@/components/map/leaflet-map", () => ({
+ LeafletMap: () => ,
+}))
+
+// The client passes a response through unparsed when it fails the schema, so a status added to the
+// contract after this build still reaches the page.
+const POSTPONED = {
+ id: "aaaa1111-0000-4000-8000-000000000001",
+ title: "Beach sweep",
+ status: "postponed" as EventStatus,
+ eventKind: "cleanup",
+ flagged: false,
+ place: "Ocean Beach",
+ attendees: 4,
+ capacity: null,
+ bags: 0,
+ organizer: { id: "u-org", name: "Olive Park", handle: "@olive", joined: "Mar 2026" },
+ date: { rel: "in 3 days", abs: "Sep 26, 9:00 AM" },
+ coords: [37.8, -122.26],
+} satisfies AdminEventListItemDTO
+
+describe("EventsPage with an event status this build does not know", () => {
+ it("renders the raw status in a neutral pill in the list and the detail pane", async () => {
+ apiMock.listAdminEvents.mockResolvedValue({
+ items: [POSTPONED],
+ nextCursor: null,
+ counts: { all: 1, upcoming: 0, in_progress: 0, completed: 0, flagged: 0 },
+ } satisfies AdminEventListResponse)
+ apiMock.getAdminEvent.mockResolvedValue({
+ ...POSTPONED,
+ desc: "About the sweep",
+ address: "Ocean Beach main entrance",
+ timeline: [],
+ messages: [],
+ linkedReports: [],
+ } satisfies AdminEventDTO)
+
+ renderWithQuery()
+
+ const listPill = await within(listCard()).findByText("postponed")
+ expect(listPill).toHaveClass("pill", "priority-low")
+ expect(within(listCard()).queryByText("Upcoming")).toBeNull()
+
+ const card = detailCard()
+ expect(await within(card).findByRole("heading", { name: "Beach sweep" })).toBeInTheDocument()
+ expect(within(card).getByText("postponed")).toHaveClass("pill", "priority-low")
+ expect(screen.queryByText("Upcoming", { selector: ".pill" })).toBeNull()
+ })
+})
+
+describe("the org events tab with an event status this build does not know", () => {
+ it("renders the raw status in a neutral pill, not as Upcoming", async () => {
+ apiMock.adminListOrgEvents.mockResolvedValue({
+ items: [POSTPONED],
+ nextCursor: null,
+ } satisfies AdminOrgEventListResponse)
+ const org = { id: "org-1", eventCount: 1 } as AdminOrgDTO
+
+ renderWithQuery()
+
+ const row = queueRowOf(await screen.findByText("Beach sweep"))
+ expect(within(row).getByText("postponed")).toHaveClass("pill", "priority-low")
+ expect(within(row).queryByText("Upcoming")).toBeNull()
+ })
+})
diff --git a/apps/admin/src/features/events/event-turnout-card.tsx b/apps/admin/src/features/events/event-turnout-card.tsx
new file mode 100644
index 0000000..aa4bafe
--- /dev/null
+++ b/apps/admin/src/features/events/event-turnout-card.tsx
@@ -0,0 +1,123 @@
+"use client"
+
+import * as React from "react"
+import type { AdminEventDTO } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { parseBags } from "@/features/events/event-outcome"
+import { turnoutLabel, turnoutPercent } from "@/features/events/event-turnout"
+import { useSetEventOutcome } from "@/features/events/use-events"
+
+function TurnoutMeter({ event }: { event: AdminEventDTO }) {
+ const percent = turnoutPercent(event.attendees, event.capacity)
+ return (
+
+
+
+ {event.attendees}
+ {event.capacity != null && / {event.capacity}}
+
+ {turnoutLabel(event.status)}
+
+ {event.capacity != null && (
+
+
+
+ )}
+
+ )
+}
+
+function BagsCollected({ event }: { event: AdminEventDTO }) {
+ if (event.bags > 0) {
+ return (
+
+
+ {event.bags} {event.bags === 1 ? "bag" : "bags"} collected
+
+
+ )
+ }
+ if (event.status !== "completed") return null
+ return (
+
+
+ No outcome logged
+
+
+ )
+}
+
+function OutcomeForm({
+ hasOutcome,
+ bagsInput,
+ onBagsInputChange,
+ submitDisabled,
+ onSubmit,
+}: {
+ hasOutcome: boolean
+ bagsInput: string
+ onBagsInputChange: (value: string) => void
+ submitDisabled: boolean
+ onSubmit: () => void
+}) {
+ return (
+
+ onBagsInputChange(e.target.value)}
+ style={{ width: 84 }}
+ />
+
+
+ )
+}
+
+export function useOutcomeDraft() {
+ const outcomeMutation = useSetEventOutcome()
+ const [bagsInput, setBagsInput] = React.useState("")
+ return { outcomeMutation, bagsInput, setBagsInput }
+}
+
+export function EventTurnoutCard({
+ event,
+ outcome,
+}: {
+ event: AdminEventDTO
+ outcome: ReturnType
+}) {
+ const { outcomeMutation, bagsInput, setBagsInput } = outcome
+ const bags = parseBags(bagsInput)
+ const canLogOutcome = event.status === "in_progress" || event.status === "completed"
+
+ const logOutcome = () => {
+ if (bags === null) return
+ outcomeMutation.mutate({ id: event.id, bags }, { onSuccess: () => setBagsInput("") })
+ }
+
+ return (
+
+
Turnout
+
+
+
+ {canLogOutcome && (
+ 0}
+ bagsInput={bagsInput}
+ onBagsInputChange={setBagsInput}
+ submitDisabled={outcomeMutation.isPending || bags === null}
+ onSubmit={logOutcome}
+ />
+ )}
+
+
+ )
+}
diff --git a/apps/admin/src/features/events/event-turnout.test.ts b/apps/admin/src/features/events/event-turnout.test.ts
new file mode 100644
index 0000000..5a1702c
--- /dev/null
+++ b/apps/admin/src/features/events/event-turnout.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest"
+
+import { turnoutLabel, turnoutPercent } from "./event-turnout"
+
+describe("turnoutPercent", () => {
+ it("reads zero when there is no capacity to fill", () => {
+ expect(turnoutPercent(12, null)).toBe(0)
+ expect(turnoutPercent(12, undefined)).toBe(0)
+ expect(turnoutPercent(12, 0)).toBe(0)
+ })
+
+ it("rounds to a whole percent of capacity", () => {
+ expect(turnoutPercent(1, 3)).toBe(33)
+ expect(turnoutPercent(2, 3)).toBe(67)
+ })
+
+ it("caps an overbooked event at a full bar", () => {
+ expect(turnoutPercent(30, 20)).toBe(100)
+ })
+})
+
+describe("turnoutLabel", () => {
+ it("words the count by where the event stands", () => {
+ expect(turnoutLabel("cancelled")).toBe("had RSVP’d")
+ expect(turnoutLabel("completed")).toBe("attended")
+ expect(turnoutLabel("upcoming")).toBe("RSVP’d")
+ expect(turnoutLabel("in_progress")).toBe("RSVP’d")
+ })
+})
diff --git a/apps/admin/src/features/events/event-turnout.ts b/apps/admin/src/features/events/event-turnout.ts
new file mode 100644
index 0000000..1e08af2
--- /dev/null
+++ b/apps/admin/src/features/events/event-turnout.ts
@@ -0,0 +1,14 @@
+import type { AdminEventDTO } from "@civfix/shared"
+
+const FULL_PERCENT = 100
+
+export function turnoutPercent(attendees: number, capacity: number | null | undefined): number {
+ if (!capacity) return 0
+ return Math.min(FULL_PERCENT, Math.round((attendees / capacity) * FULL_PERCENT))
+}
+
+export function turnoutLabel(status: AdminEventDTO["status"]): string {
+ if (status === "cancelled") return "had RSVP’d"
+ if (status === "completed") return "attended"
+ return "RSVP’d"
+}
diff --git a/apps/admin/src/features/events/events-page.test.tsx b/apps/admin/src/features/events/events-page.test.tsx
new file mode 100644
index 0000000..a30a8c3
--- /dev/null
+++ b/apps/admin/src/features/events/events-page.test.tsx
@@ -0,0 +1,588 @@
+import { act, screen, waitFor, within } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import type {
+ AdminEventDTO,
+ AdminEventListItemDTO,
+ AdminEventListResponse,
+ AdminReportListItemDTO,
+ AdminReportListResponse,
+} from "@civfix/shared"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+import type * as ApiModule from "@/lib/api"
+import { apiMock } from "@/test/api-mock"
+import { startFakeTimersWithUser } from "@/test/fake-timers"
+import { renderWithQuery } from "@/test/render"
+import { detailCard } from "@/test/panes"
+import { DialogHost } from "@/components/shared/dialog"
+import { EventsPage } from "@/features/events/events-page"
+import { useUiStore } from "@/store/ui-store"
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const { apiMock } = await import("@/test/api-mock")
+ return { ...(await importOriginal()), api: apiMock }
+})
+
+// Leaflet needs real layout; the minimap is irrelevant to the list/detail states pinned here.
+vi.mock("@/components/map/leaflet-map", () => ({
+ LeafletMap: () => ,
+}))
+
+function listItem(over: Partial & { id: string; title: string }) {
+ return {
+ status: "upcoming",
+ eventKind: "cleanup",
+ flagged: false,
+ place: "Lake Merritt",
+ attendees: 0,
+ capacity: null,
+ bags: 0,
+ organizer: { id: "u-org", name: "Olive Park", handle: "@olive", joined: "Mar 2026" },
+ date: { rel: "in 3 days", abs: "Sep 26, 9:00 AM" },
+ coords: [37.8, -122.26],
+ ...over,
+ } satisfies AdminEventListItemDTO
+}
+
+const BEACH = listItem({
+ id: "aaaa1111-0000-4000-8000-000000000001",
+ title: "Beach sweep",
+ place: "Ocean Beach",
+ attendees: 14,
+ capacity: 20,
+})
+const PARK = listItem({
+ id: "bbbb2222-0000-4000-8000-000000000002",
+ title: "Park tidy",
+ status: "completed",
+ flagged: true,
+ attendees: 6,
+ bags: 9,
+ organizer: { id: "u-sam", name: "Sam Lee", handle: "@samlee", joined: "-" },
+})
+
+const COUNTS = { all: 7, upcoming: 3, in_progress: 1, completed: 2, flagged: 1 }
+
+function page(items: AdminEventListItemDTO[], nextCursor: string | null = null) {
+ return { items, nextCursor, counts: COUNTS } satisfies AdminEventListResponse
+}
+
+function detail(item: AdminEventListItemDTO): AdminEventDTO {
+ return {
+ ...item,
+ desc: `About ${item.title}`,
+ address: `${item.place} main entrance`,
+ timeline: [{ who: "Olive", what: "created the event", when: "2d ago", kind: "create" }],
+ messages: [],
+ linkedReports: [],
+ } satisfies AdminEventDTO
+}
+
+function mockDetails(...events: AdminEventListItemDTO[]) {
+ apiMock.getAdminEvent.mockImplementation(async ({ id }: { id: string }) => {
+ const e = events.find((x) => x.id === id)
+ if (!e) throw new Error(`no event ${id}`)
+ return detail(e)
+ })
+}
+
+function listCard() {
+ return screen.getByRole("heading", { level: 3, name: "Events" }).closest("section") as HTMLElement
+}
+
+describe("EventsPage", () => {
+ it("shows the loading state while the first page is in flight", () => {
+ apiMock.listAdminEvents.mockReturnValue(new Promise(() => {}))
+ renderWithQuery()
+ expect(screen.getByRole("status")).toHaveTextContent("Loading events...")
+ expect(within(detailCard()).getByText("No event selected")).toBeInTheDocument()
+ })
+
+ it("shows the empty copy when no event matches", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([]))
+ renderWithQuery()
+ expect(await screen.findByText("Nothing matches")).toBeInTheDocument()
+ expect(screen.getByText("Try a different filter or search.")).toBeInTheDocument()
+ expect(within(detailCard()).getByText("Pick an event from the list.")).toBeInTheDocument()
+ })
+
+ it("shows the error state with the failure message and a retry", async () => {
+ apiMock.listAdminEvents.mockRejectedValue(new Error("Events backend unreachable"))
+ renderWithQuery()
+ const alert = await screen.findByRole("alert")
+ expect(alert).toHaveTextContent("Could not load this")
+ expect(alert).toHaveTextContent("Events backend unreachable")
+ expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument()
+ })
+
+ it("renders rows with key fields and auto-selects the first event into the detail pane", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH, PARK]))
+ mockDetails(BEACH, PARK)
+ renderWithQuery()
+
+ await screen.findByText("Beach sweep")
+ const list = listCard()
+ expect(within(list).getByText("#aaaa1111")).toBeInTheDocument()
+ expect(within(list).getByText("Ocean Beach")).toBeInTheDocument()
+ expect(within(list).getByText("14 attending")).toBeInTheDocument()
+ expect(within(list).getByText("Olive")).toBeInTheDocument()
+ expect(within(list).getByText("Sam")).toBeInTheDocument()
+ expect(within(list).getByText("Upcoming")).toBeInTheDocument()
+ expect(within(list).getAllByText("Sep 26, 9:00 AM")).toHaveLength(2)
+ expect(within(list).getByText("Park tidy")).toBeInTheDocument()
+ expect(within(list).getByText("Completed")).toBeInTheDocument()
+ expect(within(list).getByTitle("Flagged")).toBeInTheDocument()
+ expect(apiMock.listAdminEvents).toHaveBeenCalledWith({})
+
+ const card = detailCard()
+ expect(await within(card).findByRole("heading", { name: "Beach sweep" })).toBeInTheDocument()
+ expect(within(card).getByText("#aaaa1111 · Cleanup")).toBeInTheDocument()
+ expect(within(card).getByText("About Beach sweep")).toBeInTheDocument()
+ expect(within(card).getByText("Ocean Beach main entrance")).toBeInTheDocument()
+ expect(within(card).getByText("created the event")).toBeInTheDocument()
+ expect(within(card).getByText("No linked reports")).toBeInTheDocument()
+ expect(within(card).getByText("Olive Park")).toBeInTheDocument()
+ expect(within(card).getByPlaceholderText("Post an update to 14 attendees…")).toBeInTheDocument()
+ expect(within(card).getByRole("button", { name: /Cancel event/ })).toBeEnabled()
+ expect(apiMock.getAdminEvent).toHaveBeenCalledWith({ id: BEACH.id })
+ })
+
+ it("opens the clicked event in the detail pane", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH, PARK]))
+ mockDetails(BEACH, PARK)
+ renderWithQuery()
+ await within(detailCard()).findByRole("heading", { name: "Beach sweep" })
+
+ await userEvent.click(within(listCard()).getByText("Park tidy"))
+
+ const card = detailCard()
+ expect(await within(card).findByRole("heading", { name: "Park tidy" })).toBeInTheDocument()
+ // Both the status badge and the toggled flag button read "Flagged".
+ expect(within(card).getAllByText("Flagged")).toHaveLength(2)
+ expect(within(card).getByRole("button", { name: "Flagged" })).toBeInTheDocument()
+ expect(within(card).getByText("9")).toBeInTheDocument()
+ expect(within(card).getByText(/bags collected/)).toBeInTheDocument()
+ expect(within(card).getByRole("button", { name: /Cancel event/ })).toBeDisabled()
+ expect(apiMock.getAdminEvent).toHaveBeenCalledWith({ id: PARK.id })
+ })
+
+ it("shows the facet counts on the filter chips", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH]))
+ mockDetails(BEACH)
+ renderWithQuery()
+ await screen.findByText("Beach sweep")
+ expect(screen.getByRole("button", { name: "All 7" })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: "In progress 1" })).toBeInTheDocument()
+ expect(screen.getByRole("button", { name: "Flagged 1" })).toBeInTheDocument()
+ })
+
+ it("sends the chosen filter chip to the api", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH]))
+ mockDetails(BEACH)
+ renderWithQuery()
+ await screen.findByText("Beach sweep")
+
+ await userEvent.click(screen.getByRole("button", { name: /^In progress/ }))
+ await waitFor(() =>
+ expect(apiMock.listAdminEvents).toHaveBeenLastCalledWith({ filter: "in_progress" }),
+ )
+
+ await userEvent.click(screen.getByRole("button", { name: /^Flagged/ }))
+ await waitFor(() =>
+ expect(apiMock.listAdminEvents).toHaveBeenLastCalledWith({ filter: "flagged" }),
+ )
+ })
+
+ it("sends the trimmed search text to the api after the debounce", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH]))
+ mockDetails(BEACH)
+ renderWithQuery()
+ await screen.findByText("Beach sweep")
+
+ const user = startFakeTimersWithUser()
+ await user.type(screen.getByPlaceholderText("Search title, place, organizer…"), " beach ")
+ await act(async () => {
+ vi.advanceTimersByTime(250)
+ })
+ expect(apiMock.listAdminEvents).toHaveBeenLastCalledWith({ q: "beach" })
+ })
+
+ it("shows Load more when a cursor is returned and fetches the next page with it", async () => {
+ apiMock.listAdminEvents.mockImplementation(async (params: { cursor?: string }) =>
+ params.cursor === "ev-2" ? page([PARK]) : page([BEACH], "ev-2"),
+ )
+ mockDetails(BEACH, PARK)
+ renderWithQuery()
+ await screen.findByText("Beach sweep")
+
+ await userEvent.click(screen.getByRole("button", { name: "Load more" }))
+
+ expect(await within(listCard()).findByText("Park tidy")).toBeInTheDocument()
+ expect(apiMock.listAdminEvents).toHaveBeenLastCalledWith({ cursor: "ev-2" })
+ expect(within(listCard()).getByText("Beach sweep")).toBeInTheDocument()
+ expect(screen.queryByRole("button", { name: "Load more" })).not.toBeInTheDocument()
+ })
+
+ it("opens a deep-linked focusId that is on the first page", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH, PARK]))
+ mockDetails(BEACH, PARK)
+ renderWithQuery()
+ expect(await within(detailCard()).findByRole("heading", { name: "Park tidy" })).toBeInTheDocument()
+ expect(within(detailCard()).queryByRole("heading", { name: "Beach sweep" })).not.toBeInTheDocument()
+ })
+
+ it("keeps a deep-linked focusId that is not on the first page", async () => {
+ const OTHER = listItem({ id: "cccc3333-0000-4000-8000-000000000003", title: "Creek haul" })
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH, PARK]))
+ mockDetails(BEACH, PARK, OTHER)
+ renderWithQuery()
+ expect(await within(detailCard()).findByRole("heading", { name: "Creek haul" })).toBeInTheDocument()
+ await screen.findByText("Beach sweep")
+ expect(within(detailCard()).getByRole("heading", { name: "Creek haul" })).toBeInTheDocument()
+ expect(within(detailCard()).queryByRole("heading", { name: "Beach sweep" })).not.toBeInTheDocument()
+ expect(apiMock.getAdminEvent).toHaveBeenCalledWith({ id: OTHER.id })
+ })
+})
+
+function renderPage(focusId: string | null = null) {
+ return renderWithQuery(
+ <>
+
+
+ >,
+ )
+}
+
+function deferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((r) => {
+ resolve = r
+ })
+ return { promise, resolve }
+}
+
+const LIVE = listItem({
+ id: "dddd4444-0000-4000-8000-000000000004",
+ title: "Creek haul",
+ status: "in_progress",
+ attendees: 1,
+ bags: 1,
+})
+
+function report(id: string, title: string): AdminReportListItemDTO {
+ return {
+ id,
+ title,
+ category: "trash",
+ status: "published",
+ flagged: false,
+ place: "Ocean Beach",
+ reporter: { id: "u-rita", name: "Rita Gomez", handle: "@rita", joined: "Feb 2026" },
+ confirmations: 0,
+ submitted: { rel: "3h", abs: "Sep 23, 2026, 9:00 AM" },
+ coords: [37.8, -122.26],
+ address: "Great Hwy",
+ hasPhoto: false,
+ thumbnailUrl: null,
+ }
+}
+
+const PIER = report("eeee5555-0000-4000-8000-000000000005", "Trash pile by the pier")
+const DUNE = report("ffff6666-0000-4000-8000-000000000006", "Bottles in the dunes")
+const REPORT_COUNTS = { all: 2, submitted: 2, in_progress: 0, completed: 0, flagged: 0 }
+
+function reportPage(items: AdminReportListItemDTO[], nextCursor: string | null = null) {
+ return { items, nextCursor, counts: REPORT_COUNTS } satisfies AdminReportListResponse
+}
+
+describe("EventsPage correctness and accessibility", () => {
+ beforeEach(() => {
+ useUiStore.setState({ page: "events", focusId: null, toast: null })
+ const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {})
+ return () => {
+ scrollTo.mockRestore()
+ window.history.replaceState(null, "", "#/")
+ }
+ })
+
+ it("selects a row from the keyboard and marks it current", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH, PARK]))
+ mockDetails(BEACH, PARK)
+ const user = userEvent.setup()
+ renderPage()
+ await within(detailCard()).findByRole("heading", { name: BEACH.title })
+
+ const parkRow = within(listCard()).getByRole("button", { name: /Park tidy/ })
+ parkRow.focus()
+ await user.keyboard(" ")
+ expect(await within(detailCard()).findByRole("heading", { name: PARK.title })).toBeInTheDocument()
+ expect(parkRow).toHaveAttribute("aria-current", "true")
+ expect(within(listCard()).getByRole("button", { name: /Beach sweep/ })).not.toHaveAttribute(
+ "aria-current",
+ )
+ })
+
+ it("opens the organizer profile with Space", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH]))
+ mockDetails(BEACH)
+ const user = userEvent.setup()
+ renderPage()
+ await within(detailCard()).findByRole("heading", { name: BEACH.title })
+
+ within(listCard()).getByRole("button", { name: "Olive" }).focus()
+ await user.keyboard(" ")
+
+ expect(useUiStore.getState()).toMatchObject({ page: "users", focusId: "u-org" })
+ })
+
+ it("names the kind and flagged markers for assistive tech", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH, PARK]))
+ mockDetails(BEACH, PARK)
+ renderPage()
+ await screen.findByText(PARK.title)
+ expect(within(listCard()).getByRole("img", { name: "Flagged" })).toBeInTheDocument()
+ expect(within(listCard()).getAllByRole("img", { name: "Cleanup" })).toHaveLength(2)
+ })
+
+ it("heads the list with the server total for the active filter, not the rows loaded", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH], "ev-2"))
+ mockDetails(BEACH)
+ renderPage()
+ await screen.findByText(BEACH.title)
+ expect(within(listCard()).getByText("7")).toBeInTheDocument()
+ })
+
+ it("keeps an event the operator picked when a filter drops it from the list", async () => {
+ apiMock.listAdminEvents.mockImplementation(async (params: { filter?: string }) =>
+ params.filter === "upcoming" ? page([BEACH]) : page([BEACH, PARK]),
+ )
+ mockDetails(BEACH, PARK)
+ const user = userEvent.setup()
+ renderPage()
+ await within(detailCard()).findByRole("heading", { name: BEACH.title })
+
+ await user.click(within(listCard()).getByText(PARK.title))
+ await within(detailCard()).findByRole("heading", { name: PARK.title })
+ await user.click(screen.getByRole("button", { name: /^Upcoming/ }))
+ await waitFor(() =>
+ expect(within(listCard()).queryByText(PARK.title)).not.toBeInTheDocument(),
+ )
+
+ expect(within(detailCard()).getByRole("heading", { name: PARK.title })).toBeInTheDocument()
+ })
+
+ it("does not jump to another event after cancelling the selected one", async () => {
+ let cancelled = false
+ apiMock.listAdminEvents.mockImplementation(async () =>
+ page(cancelled ? [PARK] : [BEACH, PARK]),
+ )
+ mockDetails(BEACH, PARK)
+ apiMock.cancelEvent.mockImplementation(async () => {
+ cancelled = true
+ return { ok: true }
+ })
+ const user = userEvent.setup()
+ renderPage()
+ await within(detailCard()).findByRole("heading", { name: BEACH.title })
+
+ await user.click(within(detailCard()).getByRole("button", { name: /Cancel event/ }))
+ await user.click(
+ within(await screen.findByRole("dialog")).getByRole("button", { name: "Cancel event" }),
+ )
+
+ expect(await within(detailCard()).findByText("No event selected")).toBeInTheDocument()
+ await waitFor(() =>
+ expect(within(listCard()).queryByText(BEACH.title)).not.toBeInTheDocument(),
+ )
+ expect(within(detailCard()).getByText("No event selected")).toBeInTheDocument()
+ expect(within(detailCard()).queryByRole("heading", { name: PARK.title })).not.toBeInTheDocument()
+ })
+
+ it("keeps a cancelled event open while the All list still holds it", async () => {
+ let cancelled = false
+ const beach = () => ({ ...BEACH, status: cancelled ? ("cancelled" as const) : BEACH.status })
+ apiMock.listAdminEvents.mockImplementation(async () => page([beach(), PARK]))
+ apiMock.getAdminEvent.mockImplementation(async ({ id }: { id: string }) =>
+ detail(id === BEACH.id ? beach() : PARK),
+ )
+ apiMock.cancelEvent.mockImplementation(async () => {
+ cancelled = true
+ return { ok: true }
+ })
+ const user = userEvent.setup()
+ renderPage()
+ await within(detailCard()).findByRole("heading", { name: BEACH.title })
+
+ await user.click(within(detailCard()).getByRole("button", { name: /Cancel event/ }))
+ await user.click(
+ within(await screen.findByRole("dialog")).getByRole("button", { name: "Cancel event" }),
+ )
+
+ await waitFor(() =>
+ expect(within(detailCard()).getByRole("button", { name: /Cancel event/ })).toBeDisabled(),
+ )
+ expect(apiMock.listAdminEvents.mock.calls.length).toBeGreaterThan(1)
+ expect(within(detailCard()).getByRole("heading", { name: BEACH.title })).toBeInTheDocument()
+ expect(within(detailCard()).queryByText("No event selected")).not.toBeInTheDocument()
+ })
+
+ it("shows why Cancel is blocked as visible text tied to the button", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([PARK]))
+ mockDetails(PARK)
+ renderPage()
+ const card = detailCard()
+ await within(card).findByRole("heading", { name: PARK.title })
+
+ const reason = "This event has already ended and can't be cancelled."
+ expect(within(card).getByText(reason)).toBeInTheDocument()
+ expect(within(card).getByRole("button", { name: /Cancel event/ })).toHaveAccessibleDescription(reason)
+ })
+
+ it("posts an attendee update once when Cmd+Enter is pressed again while it is sending", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH]))
+ mockDetails(BEACH)
+ const post = deferred<{ ok: true }>()
+ apiMock.postEventMessage.mockReturnValue(post.promise)
+ const user = userEvent.setup()
+ renderPage()
+ const card = detailCard()
+ await within(card).findByRole("heading", { name: BEACH.title })
+
+ const box = within(card).getByRole("textbox", { name: "Update for attendees" })
+ await user.type(box, "Meet at the north lot")
+ await user.keyboard("{Meta>}{Enter}{/Meta}")
+ await waitFor(() => expect(within(card).getByRole("button", { name: "Post update" })).toBeDisabled())
+ await user.keyboard("{Meta>}{Enter}{/Meta}")
+ await user.keyboard("{Control>}{Enter}{/Control}")
+
+ expect(apiMock.postEventMessage).toHaveBeenCalledTimes(1)
+ await act(async () => {
+ post.resolve({ ok: true })
+ })
+ })
+
+ it("labels the attendee message box, caps it at the contract length and declares its shortcut", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH]))
+ mockDetails(BEACH)
+ renderPage()
+ const card = detailCard()
+ await within(card).findByRole("heading", { name: BEACH.title })
+
+ expect(screen.getByRole("textbox", { name: "Search events" })).toBeInTheDocument()
+ const box = within(card).getByRole("textbox", { name: "Update for attendees" })
+ expect(box).toHaveAttribute("maxlength", "4000")
+ expect(box).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter")
+ expect(within(card).getByRole("button", { name: "Post update" })).toBeInTheDocument()
+ })
+
+ it("pluralizes the bag and attendee counts", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([LIVE]))
+ mockDetails(LIVE)
+ renderPage()
+ const card = detailCard()
+ await within(card).findByRole("heading", { name: LIVE.title })
+
+ expect(within(card).getByText(/bag collected/)).toHaveTextContent("1 bag collected")
+ expect(within(card).getByPlaceholderText("Post an update to 1 attendee…")).toBeInTheDocument()
+ })
+
+ it("refuses a fractional bag count and saves an exponent one at its real value", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([LIVE]))
+ mockDetails(LIVE)
+ apiMock.setEventOutcome.mockResolvedValue({ ok: true })
+ const user = userEvent.setup()
+ renderPage()
+ const card = detailCard()
+ await within(card).findByRole("heading", { name: LIVE.title })
+ const bags = within(card).getByRole("spinbutton", { name: "Bags collected" })
+ const log = within(card).getByRole("button", { name: "Update outcome" })
+
+ await user.type(bags, "2.5")
+ expect(log).toBeDisabled()
+ await user.click(log)
+ expect(apiMock.setEventOutcome).not.toHaveBeenCalled()
+
+ await user.clear(bags)
+ await user.type(bags, "1e3")
+ await user.click(log)
+ await waitFor(() =>
+ expect(apiMock.setEventOutcome).toHaveBeenCalledWith({ id: LIVE.id, bags: 1000 }),
+ )
+ await waitFor(() => expect(useUiStore.getState().toast?.text).toBe("Outcome logged · 1000 bags"))
+
+ await user.type(bags, "1")
+ await user.click(log)
+ await waitFor(() => expect(useUiStore.getState().toast?.text).toBe("Outcome logged · 1 bag"))
+ })
+
+ it("words the flag toast from the refetched event, not the cached one", async () => {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH]))
+ mockDetails(BEACH)
+ apiMock.flagEvent.mockResolvedValue({ ok: true })
+ const user = userEvent.setup()
+ renderPage()
+ const card = detailCard()
+ await within(card).findByRole("heading", { name: BEACH.title })
+
+ // Another operator flagged it first, so this toggle cleared the flag and the refetch says so.
+ await user.click(within(card).getByRole("button", { name: "Flag" }))
+
+ await waitFor(() => expect(useUiStore.getState().toast?.text).toBe("#aaaa1111 · flag cleared"))
+ })
+})
+
+describe("EventsPage link-reports picker", () => {
+ beforeEach(() => {
+ useUiStore.setState({ page: "events", focusId: null, toast: null })
+ const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {})
+ return () => {
+ scrollTo.mockRestore()
+ window.history.replaceState(null, "", "#/")
+ }
+ })
+
+ async function openPicker() {
+ apiMock.listAdminEvents.mockResolvedValue(page([BEACH]))
+ mockDetails(BEACH)
+ const user = userEvent.setup()
+ renderPage()
+ await within(detailCard()).findByRole("heading", { name: BEACH.title })
+ await user.click(within(detailCard()).getByRole("button", { name: /Link reports/ }))
+ return { user, dialog: screen.getByRole("dialog", { name: "Link reports" }) }
+ }
+
+ it("exposes each report row as a pressed or unpressed toggle", async () => {
+ apiMock.listAdminReports.mockResolvedValue(reportPage([PIER]))
+ const { user, dialog } = await openPicker()
+
+ const row = await within(dialog).findByRole("button", { name: /Trash pile by the pier/, pressed: false })
+ await user.click(row)
+ expect(within(dialog).getByRole("button", { name: /Trash pile by the pier/, pressed: true })).toBe(row)
+ })
+
+ it("loads more reports past the first page", async () => {
+ apiMock.listAdminReports.mockImplementation(async (params: { cursor?: string }) =>
+ params.cursor === "rp-2" ? reportPage([DUNE]) : reportPage([PIER], "rp-2"),
+ )
+ const { user, dialog } = await openPicker()
+ await within(dialog).findByText(PIER.title)
+
+ await user.click(within(dialog).getByRole("button", { name: "Load more" }))
+
+ expect(await within(dialog).findByText(DUNE.title)).toBeInTheDocument()
+ expect(within(dialog).getByText(PIER.title)).toBeInTheDocument()
+ expect(apiMock.listAdminReports).toHaveBeenLastCalledWith({ cursor: "rp-2" })
+ })
+
+ it("searches once per pause in typing, not once per keystroke", async () => {
+ apiMock.listAdminReports.mockResolvedValue(reportPage([PIER]))
+ const { user, dialog } = await openPicker()
+ await within(dialog).findByText(PIER.title)
+
+ await user.type(within(dialog).getByRole("textbox", { name: "Search reports to link" }), "pier")
+
+ await waitFor(() => expect(apiMock.listAdminReports).toHaveBeenLastCalledWith({ q: "pier" }))
+ expect(apiMock.listAdminReports).not.toHaveBeenCalledWith({ q: "p" })
+ expect(apiMock.listAdminReports).not.toHaveBeenCalledWith({ q: "pie" })
+ })
+})
diff --git a/apps/admin/src/features/events/events-page.tsx b/apps/admin/src/features/events/events-page.tsx
index 09b6485..9d59e43 100644
--- a/apps/admin/src/features/events/events-page.tsx
+++ b/apps/admin/src/features/events/events-page.tsx
@@ -1,806 +1,70 @@
"use client"
import * as React from "react"
-import dynamic from "next/dynamic"
-import {
- REPORT_CATEGORY_LABELS,
- type AdminEventDTO,
- type AdminEventListItemDTO,
- type AdminReportListItemDTO,
- type LinkedReportRef,
-} from "@civfix/shared"
+import type { AdminEventCounts } from "@civfix/shared"
-import { Icons, type IconComponent } from "@/components/icons"
+import { Icons } from "@/components/icons"
import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives"
-import { LoadingState, ErrorState } from "@/components/shared/data-states"
-import { confirmDialog } from "@/components/shared/dialog"
+import { SearchBox } from "@/components/shared/section-list"
import { useDebounced } from "@/hooks/use-debounced"
-import { cancelBlockedFor, EVENT_STATUS_VIEW } from "@/lib/event-status"
-import { eventKindView, EVENT_KIND_PIN_KIND } from "@/lib/event-kind"
-import { categoryPinSrc } from "@/lib/category"
-import { reportStatusView } from "@/lib/report-status"
-import {
- useCancelEvent,
- useEvent,
- useEventListInfinite,
- useFlagEvent,
- useLinkReports,
- usePostEventMessage,
- useSetEventOutcome,
- useUnlinkReport,
-} from "@/features/events/use-events"
-import { useReportList } from "@/features/reports/use-reports"
-import { useNav, useToast } from "@/store/ui-store"
+import { idOf, useSelection } from "@/hooks/use-selection"
+import { EVENT_STATUS_VIEW } from "@/lib/event-status"
+import { flatPages } from "@/lib/infinite"
+import { EventDetail } from "@/features/events/event-detail"
+import { EventListPane } from "@/features/events/event-list-pane"
+import { useEventListInfinite } from "@/features/events/use-events"
import type { SectionPageProps } from "@/components/shell/page-registry"
+type EventFilter = keyof AdminEventCounts
-const LeafletMap = dynamic(() => import("@/components/map/leaflet-map").then((m) => m.LeafletMap), {
- ssr: false,
- loading: () => ,
-})
-
-const STATUS_VIEW = EVENT_STATUS_VIEW
-
-const TL_ICON: Record = {
- create: Icons.Pin,
- status: Icons.Clock,
- join: Icons.Users,
- message: Icons.Mail,
- done: Icons.Check,
- warn: Icons.AlertTriangle,
- cancel: Icons.Trash,
- linked: Icons.Layers,
- unlinked: Icons.X,
-}
-
-function firstName(name: string): string {
- return name.split(" ")[0] ?? name
-}
-
-function shortId(id: string): string {
- return `#${id.replace(/-/g, "").slice(0, 8)}`
+const EMPTY_COUNTS: AdminEventCounts = {
+ all: 0,
+ upcoming: 0,
+ in_progress: 0,
+ completed: 0,
+ flagged: 0,
}
-function initials(name: string): string {
- return name
- .split(" ")
- .map((w) => w[0] ?? "")
- .slice(0, 2)
- .join("")
- .toUpperCase()
-}
-
-function LinkedReportCard({
- report,
- onOpen,
- onUnlink,
- unlinking,
-}: {
- report: LinkedReportRef
- onOpen: () => void
- onUnlink?: () => void
- unlinking?: boolean
-}) {
- const view = reportStatusView(report.status)
- const pin = categoryPinSrc(report.category)
- const card = (
-
- )
- if (!onUnlink) return card
- return (
-
- {card}
-
-
- )
-}
-
-function LinkReportsPicker({
- excludeIds,
- pending,
- onClose,
- onLink,
-}: {
- excludeIds: Set
- pending: boolean
- onClose: () => void
- onLink: (reportIds: string[]) => void
-}) {
- const [query, setQuery] = React.useState("")
- const [picked, setPicked] = React.useState>(() => new Set())
-
- const listQuery = useReportList({ q: query.trim() || undefined })
- const candidates = React.useMemo(
- () => (listQuery.data?.items ?? []).filter((r) => !excludeIds.has(r.id)),
- [listQuery.data, excludeIds],
- )
-
- const toggle = (id: string) => {
- setPicked((cur) => {
- const next = new Set(cur)
- if (next.has(id)) next.delete(id)
- else next.add(id)
- return next
- })
- }
-
- const canLink = picked.size > 0 && !pending
-
- return (
-
-
e.stopPropagation()}>
-
-
Link reports
-
-
-
-
-
- setQuery(e.target.value)}
- autoFocus
- />
-
- {listQuery.isLoading ? (
-
- ) : listQuery.isError ? (
-
listQuery.refetch()} />
- ) : candidates.length === 0 ? (
- }
- />
- ) : (
-
- {candidates.map((r) => {
- const on = picked.has(r.id)
- const view = reportStatusView(r.status)
- return (
-
- )
- })}
-
- )}
-
-
-
- {picked.size > 0 ? `${picked.size} selected` : "Select reports to link"}
-
-
-
-
-
-
-
- )
-}
-
-function EventRow({
- item,
- selected,
- onClick,
-}: {
- item: AdminEventListItemDTO
- selected: boolean
- onClick: () => void
-}) {
- const kindView = eventKindView(item.eventKind)
- const KindIco = kindView.icon
- const nav = useNav()
- return (
-
-
-
-
-
-
-
-
- {item.title}
- {item.flagged && (
-
-
-
- )}
-
- {shortId(item.id)}
-
-
-
- {item.place}
- ·
- {item.attendees} attending
- ·
- {
- e.stopPropagation()
- nav("users", item.organizer.id)
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.stopPropagation()
- nav("users", item.organizer.id)
- }
- }}
- >
- {firstName(item.organizer.name)}
-
-
-
-
-
- {STATUS_VIEW[item.status].label}
-
- {item.date.abs}
-
-
- )
-}
-
-function EventDetail({ eventId, onCancelled }: { eventId: string; onCancelled: (id: string) => void }) {
- const q = useEvent(eventId)
- const nav = useNav()
- const toast = useToast()
-
- const flag = useFlagEvent()
- const cancel = useCancelEvent()
- const postMessage = usePostEventMessage()
- const outcome = useSetEventOutcome()
- const linkReports = useLinkReports()
- const unlinkReport = useUnlinkReport()
-
- const [text, setText] = React.useState("")
- const [bagsInput, setBagsInput] = React.useState("")
- const [pickerOpen, setPickerOpen] = React.useState(false)
-
- if (q.isLoading) return
- if (q.isError) return q.refetch()} />
- const event = q.data
- if (!event) {
- return (
- }
- />
- )
- }
-
- const pct = event.capacity ? Math.min(100, Math.round((event.attendees / event.capacity) * 100)) : 0
-
- const send = () => {
- const body = text.trim()
- if (!body) return
- postMessage.mutate(
- { id: event.id, body },
- {
- onSuccess: () => {
- setText("")
- toast("Update posted to attendees")
- },
- },
- )
- }
-
- const logOutcome = () => {
- const bags = Number.parseInt(bagsInput, 10)
- if (!Number.isFinite(bags) || bags < 0) return
- outcome.mutate(
- { id: event.id, bags },
- {
- onSuccess: () => {
- setBagsInput("")
- toast(`Outcome logged · ${bags} bags`)
- },
- },
- )
- }
-
- const onFlag = () => {
- flag.mutate(
- { id: event.id },
- {
- onSuccess: () =>
- toast(
- event.flagged
- ? `${shortId(event.id)} · flag cleared`
- : `${shortId(event.id)} · flagged for review`,
- ),
- },
- )
- }
-
- const cancelBlockedReason = cancelBlockedFor(event.status)
-
- const onCancel = async () => {
- const ok = await confirmDialog({
- title: "Cancel this event?",
- body: "Attendees will be notified that the event is cancelled. This can't be undone.",
- danger: true,
- confirmLabel: "Cancel event",
- cancelLabel: "Keep event",
- })
- if (!ok) return
- cancel.mutate(
- { id: event.id },
- {
- onSuccess: () => {
- toast(`${shortId(event.id)} · event cancelled`)
- onCancelled(event.id)
- },
- },
- )
- }
-
- const onLink = (reportIds: string[]) => {
- linkReports.mutate(
- { id: event.id, reportIds },
- {
- onSuccess: () => {
- setPickerOpen(false)
- toast(
- reportIds.length === 1
- ? `${shortId(event.id)} · 1 report linked`
- : `${shortId(event.id)} · ${reportIds.length} reports linked`,
- )
- },
- },
- )
- }
-
- const onUnlink = async (report: LinkedReportRef) => {
- const ok = await confirmDialog({
- title: "Unlink report",
- body: `This unlinks "${report.title}" from this event. The report itself is untouched.`,
- danger: true,
- confirmLabel: "Unlink",
- })
- if (!ok) return
- unlinkReport.mutate(
- { id: event.id, reportId: report.id },
- { onSuccess: () => toast(`${shortId(event.id)} · report unlinked`) },
- )
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {shortId(event.id)} · {eventKindView(event.eventKind).label}
-
-
{event.title}
-
- {event.flagged && (
-
- Flagged
-
- )}
-
- {STATUS_VIEW[event.status].label}
-
-
-
-
-
- { }
-
-
- About
-
- {event.attendees} attending
-
-
-
-
{event.desc}
-
-
- {event.address}
-
- ·
-
- {event.date.abs}
-
-
-
-
-
- { }
-
-
- { }
-
-
Activity
-
- {event.timeline.length === 0 ? (
-
} />
- ) : (
-
- {event.timeline.map((t, i) => {
- const Ico = TL_ICON[t.kind] ?? Icons.Clock
- return (
-
-
-
-
-
-
- {t.who} {t.what}
-
-
{t.when}
-
-
- )
- })}
-
- )}
-
-
-
- { }
- {event.eventKind === "cleanup" && (
-
-
- Linked reports
-
-
- {event.linkedReports.length}
-
-
-
- {event.linkedReports.length === 0 ? (
-
}
- />
- ) : (
-
- {event.linkedReports.map((r) => (
- nav("reports", r.id)}
- onUnlink={() => void onUnlink(r)}
- unlinking={unlinkReport.isPending}
- />
- ))}
-
- )}
-
-
- )}
-
-
-
- { }
-
-
Turnout
-
-
-
-
- {event.attendees}
- {event.capacity != null && (
- / {event.capacity}
- )}
-
-
- {event.status === "cancelled"
- ? "had RSVP’d"
- : event.status === "completed"
- ? "attended"
- : "RSVP’d"}
-
-
- {event.capacity != null && (
-
-
-
- )}
-
- {event.bags > 0 ? (
-
-
- {event.bags} bags collected
-
-
- ) : (
- event.status === "completed" && (
-
-
- No outcome logged
-
-
- )
- )}
- { }
- {(event.status === "in_progress" || event.status === "completed") && (
-
- setBagsInput(e.target.value)}
- style={{ width: 84 }}
- />
-
-
- )}
-
-
-
- { }
-
-
Organizer
-
-
-
- {initials(event.organizer.name)}
-
-
-
{event.organizer.name}
-
{event.organizer.handle}
-
-
- {event.organizer.joined !== "-" && (
-
-
- Joined
- {event.organizer.joined}
-
-
- )}
-
-
-
-
- { }
-
-
Message attendees
-
- {event.messages.length > 0 && (
-
- {event.messages.map((m, i) => (
-
- {m.who}
- {m.text}
- {m.when}
-
- ))}
-
- )}
-
-
-
-
-
- { }
-
-
Moderate
-
-
-
-
-
- { }
- {pickerOpen && event.eventKind === "cleanup" && (
-
r.id))}
- pending={linkReports.isPending}
- onClose={() => setPickerOpen(false)}
- onLink={onLink}
- />
- )}
-
- )
+function filterOptions(counts: AdminEventCounts) {
+ return [
+ { value: "all", label: "All", count: counts.all },
+ { value: "upcoming", label: "Upcoming", count: counts.upcoming },
+ {
+ value: "in_progress",
+ label: EVENT_STATUS_VIEW.in_progress.label,
+ count: counts.in_progress,
+ },
+ { value: "completed", label: EVENT_STATUS_VIEW.completed.label, count: counts.completed },
+ { value: "flagged", label: "Flagged", count: counts.flagged },
+ ]
}
export function EventsPage({ focusId }: SectionPageProps) {
- const [filter, setFilter] = React.useState("all")
+ const [filter, setFilter] = React.useState("all")
const [query, setQuery] = React.useState("")
- const [selId, setSelId] = React.useState(focusId)
- const debouncedQuery = useDebounced(query, 250)
+ const debouncedQuery = useDebounced(query)
const listParams = {
- filter:
- filter === "all"
- ? undefined
- : (filter as "upcoming" | "in_progress" | "completed" | "flagged"),
+ filter: filter === "all" ? undefined : filter,
q: debouncedQuery.trim() || undefined,
}
const listQuery = useEventListInfinite(listParams)
const items = React.useMemo(
- () => listQuery.data?.pages.flatMap((p) => p.items) ?? [],
+ () => flatPages(listQuery.data),
[listQuery.data],
)
- const counts = listQuery.data?.pages[0]?.counts ?? {
- all: 0,
- upcoming: 0,
- in_progress: 0,
- completed: 0,
- flagged: 0,
- }
-
- React.useEffect(() => {
- if (focusId) setSelId(focusId)
- }, [focusId])
- React.useEffect(() => {
- if (!selId && items.length) setSelId(items[0]!.id)
- if (selId && items.length && !items.some((x) => x.id === selId)) setSelId(items[0]!.id)
- }, [items, selId])
+ const serverCounts: AdminEventCounts | null = listQuery.data?.pages[0]?.counts ?? null
+ const counts = serverCounts ?? EMPTY_COUNTS
+ const listCount = serverCounts ? serverCounts[filter] : items.length
- const onCancelled = (id: string) => {
- setSelId((cur) => (cur === id ? null : cur))
- }
+ const { selectedId: selId, setSelectedId: setSelId } = useSelection({
+ focusId,
+ list: listQuery,
+ items,
+ getId: idOf,
+ listKey: JSON.stringify(listParams),
+ })
return (
<>
@@ -808,7 +72,7 @@ export function EventsPage({ focusId }: SectionPageProps) {
title="Events"
subtitle={
- Events neighbors organize on civfix — cleanups and other volunteer events alike. Track
+ Cleanups and other volunteer events neighbors organize on civfix. Track
turnout, keep them on the level, and message attendees.
}
@@ -816,79 +80,31 @@ export function EventsPage({ focusId }: SectionPageProps) {
-
-
-
Events
-
-
{items.length}
-
-
- {listQuery.isLoading ? (
-
- ) : listQuery.isError ? (
- listQuery.refetch()} />
- ) : items.length === 0 ? (
- }
- />
- ) : (
- <>
- {items.map((e) => (
- setSelId(e.id)}
- />
- ))}
- {listQuery.hasNextPage && (
-
- )}
- >
- )}
-
-
+
{selId ? (
-
+
) : (
{
+ const { apiMock } = await import("@/test/api-mock")
+ return { ...(await importOriginal()), api: apiMock }
+})
+
+vi.mock("@/components/map/leaflet-map", () => ({
+ LeafletMap: () => ,
+}))
+
+const EVENT = {
+ id: "aaaa1111-0000-4000-8000-000000000001",
+ title: "Beach sweep",
+ status: "upcoming",
+ eventKind: "cleanup",
+ flagged: false,
+ place: "Ocean Beach",
+ attendees: 4,
+ capacity: null,
+ bags: 0,
+ organizer: { id: "u-org", name: "Olive Park", handle: "@olive", joined: "Mar 2026" },
+ date: { rel: "in 3 days", abs: "Sep 26, 9:00 AM" },
+ coords: [37.8, -122.26],
+} satisfies AdminEventListItemDTO
+
+const REPORT = {
+ id: "r-1",
+ title: "Trash pile by the pier",
+ category: "trash",
+ status: "published",
+ flagged: false,
+ place: "Ocean Beach",
+ reporter: { id: "u-rita", name: "Rita Gomez", handle: "@rita", joined: "Feb 2026" },
+ confirmations: 0,
+ submitted: { rel: "3h", abs: "Sep 23, 2026, 9:00 AM" },
+ coords: [37.8, -122.26],
+ address: "Great Hwy",
+ hasPhoto: false,
+ thumbnailUrl: null,
+} satisfies AdminReportListItemDTO
+
+const TITLE = "Link reports"
+
+// Rendered inside the real shell so its global "Escape goes home" handler is live.
+async function openPicker() {
+ apiMock.listAdminEvents.mockResolvedValue({
+ items: [EVENT],
+ nextCursor: null,
+ counts: { all: 1, upcoming: 1, in_progress: 0, completed: 0, flagged: 0 },
+ } satisfies AdminEventListResponse)
+ apiMock.getAdminEvent.mockResolvedValue({
+ ...EVENT,
+ desc: "About the sweep",
+ address: "Ocean Beach main entrance",
+ timeline: [],
+ messages: [],
+ linkedReports: [],
+ } satisfies AdminEventDTO)
+ apiMock.listAdminReports.mockResolvedValue({
+ items: [REPORT],
+ nextCursor: null,
+ } as AdminReportListResponse)
+ const user = userEvent.setup()
+ renderWithQuery()
+ await waitForSectionPage()
+ // Wait on cheap text, then query the role once: a role query walks the whole shell on every DOM
+ // mutation, and on a loaded machine those walks starve the renders it is waiting for.
+ await screen.findByText("About the sweep")
+ const opener = screen.getByRole("button", { name: "Link reports" })
+ await user.click(opener)
+ const dialog = screen.getByRole("dialog", { name: TITLE })
+ await within(dialog).findByText(REPORT.title)
+ return { user, opener, dialog }
+}
+
+function reportRow(dialog: HTMLElement): HTMLElement {
+ return within(dialog).getByRole("button", { name: new RegExp(REPORT.title) })
+}
+
+// The section page is a React.lazy chunk: the first render in this worker would otherwise pay the cold
+// module transform inside the first wait's 1000 ms budget, which a loaded machine overruns.
+beforeAll(async () => {
+ await import("@/features/events/events-page")
+})
+
+beforeEach(() => {
+ window.history.replaceState(null, "", "#/events")
+ useUiStore.setState({ page: "events", focusId: null })
+})
+
+describe("Events link-reports modal", () => {
+ it("is a labelled modal dialog with a named close button", async () => {
+ const { dialog } = await openPicker()
+ expect(dialog).toHaveAttribute("aria-modal", "true")
+ expect(within(dialog).getByRole("button", { name: "Close" })).toBeInTheDocument()
+ })
+
+ it("starts in the search field and keeps Tab inside the dialog", async () => {
+ const { user, dialog } = await openPicker()
+ expect(within(dialog).getByPlaceholderText("Search title, place, reporter…")).toHaveFocus()
+
+ for (let i = 0; i < 8; i++) {
+ await user.tab()
+ expect(dialog).toContainElement(document.activeElement as HTMLElement)
+ }
+ for (let i = 0; i < 8; i++) {
+ await user.tab({ shift: true })
+ expect(dialog).toContainElement(document.activeElement as HTMLElement)
+ }
+ })
+
+ it("keeps a selection on Escape from a button and does not send the shell home", async () => {
+ const { user, dialog } = await openPicker()
+ await user.click(reportRow(dialog))
+ expect(within(dialog).getByText("1 selected")).toBeInTheDocument()
+
+ await user.keyboard("{Escape}")
+
+ expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument()
+ expect(screen.getByText("1 selected")).toBeInTheDocument()
+ expect(window.location.hash).toBe("#/events")
+ expect(useUiStore.getState().page).toBe("events")
+ })
+
+ it("keeps a selection on a backdrop click", async () => {
+ const { user, dialog } = await openPicker()
+ await user.click(reportRow(dialog))
+
+ await user.click(overlayOf(dialog))
+
+ expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument()
+ expect(screen.getByText("1 selected")).toBeInTheDocument()
+ })
+
+ it("closes with nothing selected on Escape without leaving Events, and restores focus", async () => {
+ const { user, opener, dialog } = await openPicker()
+ within(dialog).getByRole("button", { name: "Cancel" }).focus()
+
+ await user.keyboard("{Escape}")
+
+ expect(screen.queryByRole("dialog")).toBeNull()
+ expect(window.location.hash).toBe("#/events")
+ expect(useUiStore.getState().page).toBe("events")
+ expect(opener).toHaveFocus()
+ })
+
+ it("closes with nothing selected on a backdrop click", async () => {
+ const { user, dialog } = await openPicker()
+ await user.click(overlayOf(dialog))
+ expect(screen.queryByRole("dialog")).toBeNull()
+ })
+
+ it("stays open with its search, nothing selected, when a press in it is released over the backdrop", async () => {
+ const { user, dialog } = await openPicker()
+ const search = within(dialog).getByPlaceholderText("Search title, place, reporter…")
+ await user.type(search, "pier")
+
+ dragOutToBackdrop(search, overlayOf(dialog))
+
+ expect(screen.getByRole("dialog", { name: TITLE })).toBeInTheDocument()
+ expect(screen.getByPlaceholderText("Search title, place, reporter…")).toHaveValue("pier")
+ expect(screen.getByText("Select reports to link")).toBeInTheDocument()
+ })
+
+ it("drops a selection only through the close button, and returns focus to the opener", async () => {
+ const { user, opener, dialog } = await openPicker()
+ await user.click(reportRow(dialog))
+
+ await user.click(within(dialog).getByRole("button", { name: "Close" }))
+
+ expect(screen.queryByRole("dialog")).toBeNull()
+ expect(opener).toHaveFocus()
+ })
+})
diff --git a/apps/admin/src/features/events/link-reports-picker.tsx b/apps/admin/src/features/events/link-reports-picker.tsx
new file mode 100644
index 0000000..72f3f1b
--- /dev/null
+++ b/apps/admin/src/features/events/link-reports-picker.tsx
@@ -0,0 +1,206 @@
+"use client"
+
+import * as React from "react"
+import { REPORT_CATEGORY_LABELS, type AdminReportListItemDTO } from "@civfix/shared"
+
+import { Icons } from "@/components/icons"
+import { EmptyState } from "@/components/shared/page-primitives"
+import { LoadingState, ErrorState } from "@/components/shared/data-states"
+import { usePristineDismiss } from "@/components/shared/backdrop-dismiss"
+import { useModalFocus } from "@/components/shared/modal-focus"
+import { useDebounced } from "@/hooks/use-debounced"
+import { reportStatusView } from "@/lib/report-status"
+import { flatPages } from "@/lib/infinite"
+import { useReportListInfinite } from "@/features/reports/use-reports"
+
+type ReportListQuery = ReturnType
+
+function emptyPickerHint(searching: boolean, hasMore: boolean): string {
+ if (searching) return "Try a different search."
+ if (hasMore) return "Every report loaded so far is already linked. Load more to see older ones."
+ return "Every matching report is already linked."
+}
+
+function PickerRow({
+ report,
+ picked,
+ onToggle,
+}: {
+ report: AdminReportListItemDTO
+ picked: boolean
+ onToggle: (id: string) => void
+}) {
+ const view = reportStatusView(report.status)
+ return (
+
+ )
+}
+
+function PickerResults({
+ listQuery,
+ candidates,
+ picked,
+ searching,
+ onToggle,
+}: {
+ listQuery: ReportListQuery
+ candidates: AdminReportListItemDTO[]
+ picked: Set
+ searching: boolean
+ onToggle: (id: string) => void
+}) {
+ if (listQuery.isLoading) return
+ if (listQuery.isError) {
+ return listQuery.refetch()} />
+ }
+ if (candidates.length === 0) {
+ return (
+ }
+ />
+ )
+ }
+ return (
+
+ {candidates.map((r) => (
+
+ ))}
+
+ )
+}
+
+export function LinkReportsPicker({
+ excludeIds,
+ pending,
+ onClose,
+ onLink,
+}: {
+ excludeIds: Set
+ pending: boolean
+ onClose: () => void
+ onLink: (reportIds: string[]) => void
+}) {
+ const [query, setQuery] = React.useState("")
+ const debouncedQuery = useDebounced(query).trim()
+ const [picked, setPicked] = React.useState>(() => new Set())
+ const modalRef = useModalFocus(true)
+ const searchRef = React.useRef(null)
+ const titleId = React.useId()
+
+ // Focused here rather than with autoFocus: autoFocus lands before useModalFocus records the element
+ // to restore, so closing would return focus to the dead field instead of the opener.
+ React.useEffect(() => {
+ searchRef.current?.focus()
+ }, [])
+
+ // The search text is cheap to retype, so only a selection makes the picker a draft worth keeping.
+ const backdrop = usePristineDismiss(onClose, picked.size === 0)
+
+ const listQuery = useReportListInfinite(
+ { q: debouncedQuery || undefined },
+ { keepPreviousData: true },
+ )
+ const candidates = React.useMemo(
+ () =>
+ flatPages(listQuery.data).filter((r) => !excludeIds.has(r.id)),
+ [listQuery.data, excludeIds],
+ )
+
+ const toggle = (id: string) => {
+ setPicked((cur) => {
+ const next = new Set(cur)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const canLink = picked.size > 0 && !pending
+
+ return (
+
+
+
+
Link reports
+
+
+
+
+
+ setQuery(e.target.value)}
+ />
+
+
+ {!listQuery.isLoading && !listQuery.isError && listQuery.hasNextPage && (
+
+ )}
+
+
+
+ {picked.size > 0 ? `${picked.size} selected` : "Select reports to link"}
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/features/events/use-events-invalidation.test.tsx b/apps/admin/src/features/events/use-events-invalidation.test.tsx
new file mode 100644
index 0000000..50e7661
--- /dev/null
+++ b/apps/admin/src/features/events/use-events-invalidation.test.tsx
@@ -0,0 +1,48 @@
+import type { ReactNode } from "react"
+import { QueryClientProvider } from "@tanstack/react-query"
+import { act, renderHook } from "@testing-library/react"
+import { describe, expect, it, vi } from "vitest"
+
+import type * as ApiModule from "@/lib/api"
+import { queryKeys } from "@/lib/query"
+import { apiMock } from "@/test/api-mock"
+import { makeTestQueryClient } from "@/test/render"
+import { useCancelEvent, useFlagEvent, useSetEventOutcome } from "@/features/events/use-events"
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const { apiMock } = await import("@/test/api-mock")
+ return { ...(await importOriginal()), api: apiMock }
+})
+
+async function invalidatedBy(
+ useHook: () => { mutateAsync: (input: I) => Promise },
+ input: I,
+): Promise {
+ const client = makeTestQueryClient()
+ const spy = vi.spyOn(client, "invalidateQueries")
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ )
+ const { result } = renderHook(useHook, { wrapper })
+ await act(() => result.current.mutateAsync(input))
+ return spy.mock.calls.map(([filters]) => filters?.queryKey)
+}
+
+describe("event mutations refresh the sections that show event state", () => {
+ it.each([
+ ["flag", "flagEvent", () => useFlagEvent(), { id: "e1" }],
+ ["cancel", "cancelEvent", () => useCancelEvent(), { id: "e1" }],
+ ["outcome", "setEventOutcome", () => useSetEventOutcome(), { id: "e1", bags: 3 }],
+ ] as const)("%s", async (_name, method, useHook, input) => {
+ apiMock[method].mockResolvedValue({ ok: true })
+ const keys = await invalidatedBy(useHook as never, input)
+ expect(keys).toEqual(
+ expect.arrayContaining([
+ queryKeys.events.all,
+ queryKeys.users.all,
+ queryKeys.orgs.all,
+ queryKeys.pages.all,
+ ]),
+ )
+ })
+})
diff --git a/apps/admin/src/features/events/use-events.ts b/apps/admin/src/features/events/use-events.ts
index 0e3814f..1725d27 100644
--- a/apps/admin/src/features/events/use-events.ts
+++ b/apps/admin/src/features/events/use-events.ts
@@ -2,10 +2,12 @@
import {
keepPreviousData,
+ queryOptions,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
+ type QueryClient,
} from "@tanstack/react-query"
import type {
AdminEventListQuery,
@@ -19,8 +21,15 @@ import type {
} from "@civfix/shared"
import { api } from "@/lib/api"
-import { queryKeys } from "@/lib/query"
+import { shortRef } from "@/lib/display"
+import { infiniteListOptions } from "@/lib/infinite"
+import { invalidateKeys, queryKeys } from "@/lib/query"
+import { useUiStore } from "@/store/ui-store"
+import { pluralize } from "@/features/reports/plural"
+function eventMessage(id: string, text: string): string {
+ return `${shortRef(id)} · ${text}`
+}
export function useEventList(params: AdminEventListQuery, opts: { keepPreviousData?: boolean } = {}) {
return useQuery({
@@ -31,37 +40,55 @@ export function useEventList(params: AdminEventListQuery, opts: { keepPreviousDa
}
export function useEventListInfinite(params: AdminEventListQuery) {
- return useInfiniteQuery({
- queryKey: queryKeys.events.list(params),
- queryFn: ({ pageParam }) =>
- api.listAdminEvents({
- ...params,
- ...(typeof pageParam === "string" ? { cursor: pageParam } : {}),
- }),
- initialPageParam: undefined as string | undefined,
- getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
+ return useInfiniteQuery(
+ infiniteListOptions(queryKeys.events.list(params), params, (input) =>
+ api.listAdminEvents(input),
+ ),
+ )
+}
+
+function eventQuery(id: string) {
+ return queryOptions({
+ queryKey: queryKeys.events.detail(id),
+ queryFn: () => api.getAdminEvent({ id }),
})
}
export function useEvent(id: string | null) {
- return useQuery({
- queryKey: queryKeys.events.detail(id ?? ""),
- queryFn: () => api.getAdminEvent({ id: id as string }),
- enabled: !!id,
- })
+ return useQuery({ ...eventQuery(id ?? ""), enabled: !!id })
}
-function invalidateEvents(qc: ReturnType, id: string) {
- qc.invalidateQueries({ queryKey: queryKeys.events.detail(id) })
- qc.invalidateQueries({ queryKey: queryKeys.events.all })
- qc.invalidateQueries({ queryKey: queryKeys.home.all })
+// Profiles, org event tabs and signup pages all render an event's status, turnout and flag.
+function invalidateEvents(qc: QueryClient, id: string) {
+ return invalidateKeys(qc, [
+ queryKeys.events.detail(id),
+ queryKeys.events.all,
+ queryKeys.home.all,
+ queryKeys.users.all,
+ queryKeys.orgs.all,
+ queryKeys.pages.all,
+ ])
}
export function useFlagEvent() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: FlagEventRequest) => api.flagEvent(input),
- onSuccess: (_res, { id }) => invalidateEvents(qc, id),
+ // The endpoint toggles, so another operator's flag since this load flips the outcome: word the
+ // toast from the event as it now is.
+ onSuccess: async (_res, { id }) => {
+ await invalidateEvents(qc, id)
+ let event: GetAdminEventResponse
+ try {
+ event = await qc.fetchQuery(eventQuery(id))
+ } catch {
+ // The flag already changed and the refetch failed: skip the toast rather than guess its wording.
+ return
+ }
+ useUiStore
+ .getState()
+ .showToast(eventMessage(id, event.flagged ? "flagged for review" : "flag cleared"))
+ },
})
}
@@ -70,6 +97,9 @@ export function useCancelEvent() {
return useMutation({
mutationFn: (input: CancelRequest) => api.cancelEvent(input),
onSuccess: (_res, { id }) => invalidateEvents(qc, id),
+ meta: {
+ successMessage: (_res: unknown, { id }: CancelRequest) => eventMessage(id, "event cancelled"),
+ },
})
}
@@ -78,6 +108,7 @@ export function usePostEventMessage() {
return useMutation({
mutationFn: (input: PostMessageRequest) => api.postEventMessage(input),
onSuccess: (_res, { id }) => invalidateEvents(qc, id),
+ meta: { successMessage: () => "Update posted to attendees" },
})
}
@@ -86,6 +117,10 @@ export function useSetEventOutcome() {
return useMutation({
mutationFn: (input: SetEventOutcomeRequest) => api.setEventOutcome(input),
onSuccess: (_res, { id }) => invalidateEvents(qc, id),
+ meta: {
+ successMessage: (_res: unknown, { bags }: SetEventOutcomeRequest) =>
+ `Outcome logged · ${pluralize(bags, "bag")}`,
+ },
})
}
@@ -93,9 +128,17 @@ export function useLinkReports() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: LinkEventReportsRequest) => api.linkEventReports(input),
- onSuccess: (_res, { id }) => {
- invalidateEvents(qc, id)
- qc.invalidateQueries({ queryKey: queryKeys.reports.all })
+ onSuccess: (_res, { id }) =>
+ Promise.all([
+ invalidateEvents(qc, id),
+ qc.invalidateQueries({ queryKey: queryKeys.reports.all }),
+ ]),
+ meta: {
+ successMessage: (_res: unknown, { id, reportIds }: LinkEventReportsRequest) =>
+ eventMessage(
+ id,
+ reportIds.length === 1 ? "1 report linked" : `${reportIds.length} reports linked`,
+ ),
},
})
}
@@ -104,10 +147,13 @@ export function useUnlinkReport() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: { id: string; reportId: string }) => api.unlinkEventReport(input),
- onSuccess: (_res, { id, reportId }) => {
- invalidateEvents(qc, id)
- qc.invalidateQueries({ queryKey: queryKeys.reports.detail(reportId) })
- qc.invalidateQueries({ queryKey: queryKeys.reports.all })
+ onSuccess: (_res, { id }) =>
+ Promise.all([
+ invalidateEvents(qc, id),
+ qc.invalidateQueries({ queryKey: queryKeys.reports.all }),
+ ]),
+ meta: {
+ successMessage: (_res: unknown, { id }: { id: string }) => eventMessage(id, "report unlinked"),
},
})
}
diff --git a/apps/admin/src/features/home/home-accessibility.test.ts b/apps/admin/src/features/home/home-accessibility.test.ts
deleted file mode 100644
index c3c8646..0000000
--- a/apps/admin/src/features/home/home-accessibility.test.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { readFileSync } from "node:fs"
-
-import { describe, expect, it } from "vitest"
-
-const source = readFileSync(new URL("./home-page.tsx", import.meta.url), "utf8")
-const previewRowSource = source.slice(source.indexOf("function PreviewRow"), source.indexOf("interface PreviewState"))
-const sectionTileSource = source.slice(source.indexOf("function SectionTile"), source.indexOf("interface PreviewTile"))
-const nativeButtonContents = (snippet: string) =>
- [...snippet.matchAll(/