diff --git a/NOTICE b/NOTICE
index d1bcd67..1748758 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,4 +1,4 @@
-civfix — the operator dashboard
+civfix: the operator dashboard
Copyright (c) 2026 Reach Out Los Angeles Inc. and contributors
This program is free software: you can redistribute it and/or modify it under
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
index 1736904..512bd02 100644
--- a/apps/admin/src/app/layout.tsx
+++ b/apps/admin/src/app/layout.tsx
@@ -4,23 +4,15 @@ import { tokens } from "@civfix/shared/tokens"
import { Providers } from "@/components/providers"
import "./globals.css"
-// The ported design system CSS (warm-paper tokens + every shell/component class from the PinIt Admin
-// handoff). Imported AFTER globals.css so the design's component classes win over Tailwind's base
-// reset wherever they overlap on the shell. admin.css @imports colors-and-type.css itself.
+// After globals.css so the design system's component classes win over Tailwind's base reset where
+// they overlap on the shell.
import "@/styles/admin.css"
-// Dashboard app additions the prototype lacked: the Cloudflare Access sign-in gate, boot screen, inline
-// spinner, and loading / error / empty states. Built on the same design tokens; imported last so it can
-// layer on top of the ported design CSS.
+// Last, so the app's own additions layer on top of the design system CSS.
import "@/styles/app.css"
-/**
- * Fonts are loaded via next/font/google and exposed DIRECTLY as the CSS variables the ported design CSS
- * consumes (--font-display-next / --font-body-next / --font-mono-next, see colors-and-type.css). Those
- * design vars then build the final --font-display / --font-body / --font-mono stacks with literal
- * fallbacks. The next/font variable names MUST differ from the design's own --font-display/body/mono, or
- * the alias becomes a circular var() reference (which CSS invalidates -> serif fallback). The families
- * match tokens.font (Bricolage Grotesque / Manrope / JetBrains Mono), identical to community-web.
- */
+// colors-and-type.css builds the --font-display/body/mono stacks from these *-next variables. The names
+// must differ from the stacks' own, or the alias becomes a circular var() that CSS invalidates, falling
+// back to serif.
const display = Bricolage_Grotesque({
subsets: ["latin"],
variable: "--font-display-next",
@@ -49,7 +41,6 @@ export const metadata: Metadata = {
}
export const viewport: Viewport = {
- // Sourced from the shared token (neutral.paper) so browser chrome matches the app background.
themeColor: tokens.color.neutral.paper,
width: "device-width",
initialScale: 1,
diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx
index a554407..5d1f281 100644
--- a/apps/admin/src/app/page.tsx
+++ b/apps/admin/src/app/page.tsx
@@ -1,11 +1,7 @@
import { AppShell } from "@/components/shell/app-shell"
-/**
- * The dashboard is a single client-rooted SPA shell. The static export emits just the HTML shell + JS;
- * the login gate (providers.tsx) and AppShell render entirely on the client, and all data is fetched
- * at runtime. There is exactly one route - section navigation is client-side page state (no Next
- * routes), so the export produces a single out/index.html plus the SPA fallback in public/_redirects.
- */
+// The only route: sections are client-side page state, so the static export emits a single
+// out/index.html, served for every path by the SPA fallback in public/_redirects.
export default function HomePage() {
return
}
diff --git a/apps/admin/src/components/auth/auth-hydrator.tsx b/apps/admin/src/components/auth/auth-hydrator.tsx
index eb6f373..2f0e099 100644
--- a/apps/admin/src/components/auth/auth-hydrator.tsx
+++ b/apps/admin/src/components/auth/auth-hydrator.tsx
@@ -4,17 +4,6 @@ import * as React from "react"
import { useOperatorBootstrap } from "@/hooks/use-admin-auth"
-/**
- * Bootstraps the operator session via Cloudflare Access exactly once on mount (doc 16, same-origin).
- *
- * The SPA and the `/admin/*` API are served behind the same Access app on the same origin, so by the time
- * this runs the browser already holds the Access cookie. The bootstrap (useOperatorBootstrap) first reuses
- * a still-valid operator session (GET /admin/auth/session), and otherwise exchanges the edge-injected
- * Access JWT (POST /admin/auth/access/exchange) for one. On success the auth store flips to authenticated
- * and the gate (providers.tsx) renders the dashboard; a 403 (authenticated but not allowlisted) lands on
- * the not-authorized state; any other failure lands on the retryable login screen. No cross-origin cookie
- * bootstrap / redirect is needed in this same-origin deployment.
- */
export function AuthHydrator() {
const bootstrap = useOperatorBootstrap()
diff --git a/apps/admin/src/components/icons.tsx b/apps/admin/src/components/icons.tsx
index a4e59dc..5a772ce 100644
--- a/apps/admin/src/components/icons.tsx
+++ b/apps/admin/src/components/icons.tsx
@@ -1,23 +1,14 @@
-/**
- * Icons (Lucide-style inline SVG), ported from the design prototype (icons.jsx) to a typed TSX module.
- * Stroke 1.75, round caps/joins, currentColor. Every name the design's `Icons.*` set used is present,
- * so ported components reference `Icons.Pin`, `Icons.ChevronRight`, etc. exactly as in the prototype.
- */
-
export interface IconProps {
- /** Pixel size for width + height (viewBox stays 24). Defaults to 16. */
size?: number
fill?: string
stroke?: string
- /** Stroke width. Defaults to 1.75. */
+ /** Stroke width. */
sw?: number
className?: string
}
interface BaseIconProps extends IconProps {
- /** A single path string, OR... */
d?: string
- /** ...several path strings. */
paths?: string[]
}
@@ -48,12 +39,10 @@ function Icon({
)
}
-/** A single icon component: takes IconProps (size/fill/stroke/sw/className). */
export type IconComponent = (props: IconProps) => React.ReactElement
-// NOTE: declared as a plain object literal (not `Record`) so that each known
-// key (Icons.Pin, Icons.ChevronRight, ...) is non-optional under `noUncheckedIndexedAccess`. The
-// `satisfies` clause still enforces that every value is a valid IconComponent.
+// A plain literal with `satisfies`, not `Record`, so every known key stays
+// non-optional under `noUncheckedIndexedAccess`.
export const Icons = {
Pin: (p) => (
[0]
-/**
- * A small Leaflet map that draws ONE jurisdiction's boundary polygon and fits to it — the directory's
- * "is this in the right place?" verification view. CARTO Voyager raster basemap (same tiles as the
- * marker map.tsx); the polygon is the server-simplified GeoJSON from GET /admin/jurisdictions/:geoid/
- * geometry. Like leaflet-map.tsx this imports Leaflet at the top level, so it MUST be loaded client-only
- * via next/dynamic (ssr:false). Leaflet's CSS is imported globally in globals.css.
- */
+// Leaflet touches window at import, so this module must be loaded through next/dynamic with ssr:false.
const TILE = {
url: withCartoKey("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"),
@@ -23,7 +17,6 @@ const TILE = {
subdomains: "abcd",
}
-/** Boundary fill/stroke by jurisdiction layer, so a city reads differently from a county/federal land. */
const LAYER_COLOR: Record = {
place: "#5B8C6E",
county: "#3F7CAC",
@@ -33,11 +26,9 @@ const LAYER_COLOR: Record = {
}
export interface BoundaryMapProps {
- /** GeoJSON Polygon/MultiPolygon geometry. */
geometry: { type: string; coordinates: unknown }
- /** [west, south, east, north] for fit-bounds. */
+ /** [west, south, east, north] */
bbox: [number, number, number, number]
- /** Jurisdiction layer, drives the boundary color. */
layer?: string
}
@@ -46,13 +37,13 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp
const mapRef = React.useRef(null)
const shapeRef = React.useRef(null)
- // Create the map once.
React.useEffect(() => {
if (!elRef.current || mapRef.current) return
const map = L.map(elRef.current, {
zoomControl: true,
attributionControl: true,
- scrollWheelZoom: false, // require an explicit zoom (it sits inside a scrollable detail panel)
+ // The map sits inside a scrollable detail panel, so the wheel must scroll the panel.
+ scrollWheelZoom: false,
})
mapRef.current = map
L.tileLayer(TILE.url, {
@@ -73,10 +64,8 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp
mapRef.current = null
shapeRef.current = null
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
- // Draw / redraw the boundary and fit to its bbox whenever the geometry changes.
React.useEffect(() => {
const map = mapRef.current
if (!map) return
diff --git a/apps/admin/src/components/map/leaflet-map.tsx b/apps/admin/src/components/map/leaflet-map.tsx
index f3e9895..90458cf 100644
--- a/apps/admin/src/components/map/leaflet-map.tsx
+++ b/apps/admin/src/components/map/leaflet-map.tsx
@@ -7,24 +7,16 @@ import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation
import { withCartoKey } from "@/lib/carto"
import { CATEGORY_GLYPHS } from "@/lib/category"
-/**
- * The civfix universal map (ported from the design's map.jsx PinItMap). Real OpenStreetMap data via
- * Leaflet + CARTO raster tiles - a deliberate design-fidelity choice for this internal tool (it matches
- * the prototype exactly and does NOT use MapLibre/pmtiles like community-web).
- *
- * This module imports Leaflet at the top level, so it MUST only ever be loaded on the client. It is
- * imported via next/dynamic with `ssr:false` from live-map.tsx (and any future minimap). Leaflet's CSS
- * is imported globally in src/app/globals.css.
- */
+// Leaflet rather than community-web's MapLibre seam: a deliberate choice for this internal tool.
+// Leaflet touches window at import, so this module must be loaded through next/dynamic with ssr:false.
export interface MapPin {
- /** Stable marker id. */
id: string
lat: number
lng: number
- /** Report category for the glyph; null/omitted falls back to a generic pin. */
+ /** Null or omitted falls back to the generic glyph. */
category?: string | null
- /** When true, renders the red "needs attention" pin. */
+ /** Renders the red "needs attention" pin. */
draft?: boolean
/** "event" renders the yellow cleanup pin; "event-volunteer" the moss other-volunteer pin. */
kind?: string | null
@@ -53,8 +45,8 @@ export type MapTint = keyof typeof MAP_TILES
export const MAP_HOME = { center: [39.5, -98.35] as [number, number], zoom: 4 }
-// Brand pin as a Leaflet divIcon. Routed jurisdictions render gray with a glyph; jurisdictions that
-// still need a routing contact render red; events render yellow. (Ported from map.jsx.)
+// Report pins are gray once routed and red while they still need a routing contact; events take the
+// fill of their kind.
const TEARDROP =
"M32 4 C46 4 58 16 58 30 C58 46 40 60 34 68 C33 69 31 69 30 68 C24 60 6 46 6 30 C6 16 18 4 32 4 Z"
const GLYPHS: Record = {
@@ -62,7 +54,6 @@ const GLYPHS: Record = {
cleanup:
"M3 6 L21 6 M19 6 V20 a2 2 0 0 1 -2 2 H7 a2 2 0 0 1 -2 -2 V6 M9 6 V4 a1 1 0 0 1 1 -1 h4 a1 1 0 0 1 1 1 V6 M9 11 V17 M12 11 V17 M15 11 V17",
event: "M4 7 a1 1 0 0 1 1 -1 h14 a1 1 0 0 1 1 1 v12 a1 1 0 0 1 -1 1 H5 a1 1 0 0 1 -1 -1 Z M16 4 v4 M8 4 v4 M4 11 h16",
- // "Other Volunteer" events: a cupped-hands-with-heart glyph, distinct from the cleanup calendar.
"event-volunteer":
"M12 9 a2 2 0 0 1 3 -1.3 a2 2 0 0 1 0.5 3 L12 14 L8.5 10.7 a2 2 0 0 1 0.5 -3 A2 2 0 0 1 12 9 Z M4 13 v5 a1 1 0 0 0 1 1 h2 v-6 Z M20 13 v5 a1 1 0 0 1 -1 1 h-2 v-6 Z",
}
@@ -72,15 +63,13 @@ const PIN_FILL: Record = {
event: "#E5AE1C",
"event-volunteer": "#5B8C6E",
}
-// Marker kinds that render with the "event" family of treatments (no draft/needs override).
+// Event kinds own their fill and glyph; `draft` never overrides them.
const EVENT_KINDS = new Set(["event", "event-volunteer"])
function pinIcon(
category: string | null | undefined,
{ active = false, draft = false, kind = null }: { active?: boolean; draft?: boolean; kind?: string | null },
): L.DivIcon {
- // Event markers (cleanup / other-volunteer) own their own fill + glyph by kind; everything else is a
- // report pin (red when it needs attention, gray when handled).
const isEvent = kind != null && EVENT_KINDS.has(kind)
const state = isEvent ? kind : draft ? "needs" : "routed"
const glyphKey = isEvent ? kind : category || "other"
@@ -146,10 +135,6 @@ export interface LeafletMapProps {
onPinTap?: (pin: MapPin) => void
}
-/**
- * Imperative Leaflet wrapper. Creates the map once, swaps the tile layer on tint change, and
- * reconciles markers when `pins` / `activeId` change. Mirrors map.jsx PinItMap behavior.
- */
export function LeafletMap({
pins = [],
center = MAP_HOME.center,
@@ -163,11 +148,9 @@ export function LeafletMap({
const mapRef = React.useRef(null)
const tileRef = React.useRef(null)
const markersRef = React.useRef>({})
- // Per-id memo of the last-rendered visual descriptor (category|draft|kind|active), tooltip text and
- // position, so reconcile can skip the expensive DivIcon rebuild + DOM teardown (setIcon), the tooltip
- // rebind and the setLatLng call when nothing visible actually changed for that marker. Without this, a
- // single activeId change re-icons and DOM-replaces ALL N markers; with it, only the de-activated +
- // newly-active markers do.
+ // What each marker last rendered, so reconcile skips the DivIcon rebuild and DOM teardown, the tooltip
+ // rebind and setLatLng when nothing visible changed. Without it one activeId change re-icons every
+ // marker instead of just the two whose active state flipped.
const renderRef = React.useRef<
Record
>({})
@@ -177,7 +160,6 @@ export function LeafletMap({
const onPinTapRef = React.useRef(onPinTap)
onPinTapRef.current = onPinTap
- // Create the map once.
React.useEffect(() => {
if (!elRef.current || mapRef.current) return
const map = L.map(elRef.current, {
@@ -191,8 +173,7 @@ export function LeafletMap({
touchZoom: interactive,
boxZoom: false,
keyboard: false,
- // Note: Leaflet's legacy `tap` option was dropped from @types/leaflet (and is a no-op in modern
- // Leaflet), so it is intentionally omitted here; touch tap is handled natively.
+ // No `tap`: the legacy option is a no-op in modern Leaflet and gone from @types/leaflet.
})
mapRef.current = map
@@ -221,7 +202,7 @@ export function LeafletMap({
renderRef.current = {}
pinsRef.current = {}
}
- // Intentionally run once on mount.
+ // Created once; the effects below apply later prop changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -230,7 +211,6 @@ export function LeafletMap({
mapRef.current?.setView([centerLat, centerLng], zoom)
}, [centerLat, centerLng, zoom])
- // Swap tiles when tint changes.
React.useEffect(() => {
const map = mapRef.current
if (!map) return
@@ -244,7 +224,6 @@ export function LeafletMap({
}).addTo(map)
}, [tint])
- // Reconcile markers.
React.useEffect(() => {
const map = mapRef.current
if (!map) return
@@ -266,13 +245,11 @@ export function LeafletMap({
pinsRef.current[p.id] = p
const existing = markersRef.current[p.id]
const active = String(p.id) === String(activeId)
- // One cheap string capturing everything pinIcon() depends on. Equal key => identical DivIcon, so
- // we can skip rebuilding the HTML/SVG and the setIcon DOM teardown entirely.
+ // Everything pinIcon() depends on: an equal key means an identical DivIcon.
const key = `${p.category}|${p.draft}|${p.kind}|${active}`
const text = tooltipText(p)
if (existing) {
const prev = renderRef.current[p.id]
- // Re-icon only when the visual descriptor changed (e.g. this pin just gained/lost active).
if (!prev || prev.key !== key) {
existing.setIcon(pinIcon(p.category, { active, draft: p.draft, kind: p.kind }))
}
@@ -280,7 +257,6 @@ export function LeafletMap({
syncTooltip(existing, text)
if (interactive) labelMarker(existing, text)
}
- // Re-position only when the coordinates actually moved.
if (!prev || prev.lat !== p.lat || prev.lng !== p.lng) {
existing.setLatLng([p.lat, p.lng])
}
diff --git a/apps/admin/src/components/providers.tsx b/apps/admin/src/components/providers.tsx
index ef1b2a5..218bd78 100644
--- a/apps/admin/src/components/providers.tsx
+++ b/apps/admin/src/components/providers.tsx
@@ -9,13 +9,6 @@ import { ErrorBoundary } from "@/components/shell/error-boundary"
import { OperatorLogin } from "@/features/auth/operator-login"
import { useOperatorSession } from "@/hooks/use-admin-auth"
-/**
- * App-wide client providers. Mounted once in the root layout.
- *
- * The QueryClient is created lazily and held in a ref so it survives re-renders but is unique per
- * browser tab. AuthHydrator runs the Cloudflare Access exchange/bootstrap on mount; AuthGate then
- * decides whether to render the dashboard, the loading screen, or the operator (Access) gate.
- */
export function Providers({ children }: { children: React.ReactNode }) {
const clientRef = React.useRef(null)
if (!clientRef.current) {
@@ -32,14 +25,6 @@ export function Providers({ children }: { children: React.ReactNode }) {
)
}
-/**
- * The operator gate. Only an authenticated operator session renders the dashboard:
- * - idle / loading -> a minimal loading screen (Access exchange in flight, or pre-hydration).
- * - signing-out -> the same screen, saying so, until the Access logout navigation lands.
- * - not an operator -> the full-page Cloudflare Access gate (anonymous: authenticating + manual
- * continue; forbidden: not-authorized message).
- * - operator -> the dashboard shell (children).
- */
function AuthGate({ children }: { children: React.ReactNode }) {
const { isOperator, status } = useOperatorSession()
@@ -58,7 +43,6 @@ function AuthGate({ children }: { children: React.ReactNode }) {
return <>{children}>
}
-/** Minimal centered screen shown while the session is being established or ended. */
function BootScreen({ label }: { label: string }) {
return (
diff --git a/apps/admin/src/components/shared/backdrop-dismiss.ts b/apps/admin/src/components/shared/backdrop-dismiss.ts
index 39b11a6..8271972 100644
--- a/apps/admin/src/components/shared/backdrop-dismiss.ts
+++ b/apps/admin/src/components/shared/backdrop-dismiss.ts
@@ -37,9 +37,8 @@ export function useBackdropDismiss(
/**
* Escape and the backdrop dismiss a modal only while it holds nothing the operator would lose; once
- * there is a draft, its close button and Cancel are the deliberate ways to discard it. Returns the
- * backdrop props. The shell yields Escape to any open modal (escape-owner.ts), so Escape here closes
- * only the modal and never navigates.
+ * there is a draft, its close button and Cancel are the deliberate ways to discard it. The shell yields
+ * Escape to any open modal (escape-owner.ts), so Escape here closes only the modal and never navigates.
*/
export function usePristineDismiss(
onClose: () => void,
diff --git a/apps/admin/src/components/shared/data-states.tsx b/apps/admin/src/components/shared/data-states.tsx
index 2434909..da8f2ed 100644
--- a/apps/admin/src/components/shared/data-states.tsx
+++ b/apps/admin/src/components/shared/data-states.tsx
@@ -4,21 +4,6 @@ import * as React from "react"
import { errorMessage } from "@/lib/error-messages"
-/**
- * Standard loading / error / empty state components for data-bound views. The prototype had NONE of
- * these (data was synchronous from window.DATA); every real list/detail needs them. The section pages
- * use these so the conventions stay consistent across sections.
- *
- * Recommended pattern in a page/section:
- *
- * const q = useSomething(params)
- * if (q.isLoading) return
- * if (q.isError) return q.refetch()} />
- * if (!q.data?.items.length) return
- * // ...render q.data
- */
-
-/** Centered spinner row for in-flight queries. */
export function LoadingState({ label = "Loading..." }: { label?: string }) {
return (
@@ -28,10 +13,6 @@ export function LoadingState({ label = "Loading..." }: { label?: string }) {
)
}
-/**
- * Error panel with an optional retry and an optional primary action beside it (the error boundary's
- * Reload). Shows the error's operator copy from errorMessage unless `message` replaces it.
- */
export function ErrorState({
error,
onRetry,
@@ -69,7 +50,6 @@ export function ErrorState({
)
}
-/** A simple skeleton block; size it with width/height. Use several to fake a loading list/card. */
export function Skeleton({
width = "100%",
height = 16,
diff --git a/apps/admin/src/components/shared/page-primitives.tsx b/apps/admin/src/components/shared/page-primitives.tsx
index f9e5067..58112d2 100644
--- a/apps/admin/src/components/shared/page-primitives.tsx
+++ b/apps/admin/src/components/shared/page-primitives.tsx
@@ -4,12 +4,6 @@ import * as React from "react"
import { Icons } from "@/components/icons"
-/**
- * Shared page primitives, ported from the design (pages-shared.jsx). The section pages compose these to
- * match the prototype's structure exactly (class names + DOM preserved).
- */
-
-/** Page header with title, subtitle, and right-side actions (and optional meta on the far right). */
export function PageHead({
title,
subtitle,
@@ -18,7 +12,7 @@ export function PageHead({
}: {
title: React.ReactNode
subtitle?: React.ReactNode
- /** Right-side actions (buttons). */
+ /** Right-side actions. */
children?: React.ReactNode
meta?: React.ReactNode
}) {
@@ -34,10 +28,8 @@ export function PageHead({
)
}
-/** One option in a FilterChips control: either a bare string or a { value, label, count }. */
export type FilterOption = string | { value: string; label: string; count?: React.ReactNode }
-/** Segmented filter control (`.filter-chips`): one pressed toggle per option. */
export function FilterChips({
options,
value,
@@ -47,7 +39,6 @@ export function FilterChips({
options: FilterOption[]
value: string
onChange: (value: string) => void
- /** Names the group for assistive tech, e.g. "Status". */
ariaLabel?: string
}) {
return (
@@ -73,7 +64,6 @@ export function FilterChips({
)
}
-/** Empty state (`.empty-state`). */
export function EmptyState({
icon,
title,
diff --git a/apps/admin/src/components/shell/app-shell.tsx b/apps/admin/src/components/shell/app-shell.tsx
index 7ff3618..fcc13b9 100644
--- a/apps/admin/src/components/shell/app-shell.tsx
+++ b/apps/admin/src/components/shell/app-shell.tsx
@@ -21,15 +21,13 @@ export function AppShell() {
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const targetTag = (e.target as HTMLElement | null)?.tagName
- // An open slide-over, row menu or dialog owns Escape (it closes itself); the shell only goes
- // home when nothing is layered over the page.
if (!shellEscapeGoesHome({ key: e.key, page, targetTag, doc: document })) return
e.preventDefault()
nav("home")
}
- // Capture phase: the layer's own Escape handler (on document or window) runs later and closes
- // it, and React flushes that removal at the microtask checkpoint between listeners — a bubble-
- // phase check here would already find the layer gone and go home on top of closing it.
+ // Capture phase: the layer's own Escape handler (on document or window) runs later and closes it,
+ // and React flushes that removal between listeners, so a bubble-phase check would find the layer
+ // gone and go home on top of closing it.
window.addEventListener("keydown", onKey, true)
return () => window.removeEventListener("keydown", onKey, true)
}, [page, nav])
diff --git a/apps/admin/src/components/shell/escape-owner.test.ts b/apps/admin/src/components/shell/escape-owner.test.ts
index 34d9cf5..65592e6 100644
--- a/apps/admin/src/components/shell/escape-owner.test.ts
+++ b/apps/admin/src/components/shell/escape-owner.test.ts
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"
import { ESCAPE_OWNER_SELECTOR, hasEscapeOwner, shellEscapeGoesHome } from "./escape-owner"
-/** A document stub that reports a match for the escape-owner selector when `open` is true. */
function doc(open: boolean): Pick {
return {
querySelector: ((selector: string) =>
@@ -32,7 +31,7 @@ describe("shellEscapeGoesHome", () => {
expect(shellEscapeGoesHome({ ...base, targetTag: undefined })).toBe(true)
})
- it("stays put while a panel, menu or dialog is open — even with focus on a button", () => {
+ it("stays put while a panel, menu or dialog is open, even with focus on a button", () => {
expect(shellEscapeGoesHome({ ...base, doc: doc(true) })).toBe(false)
})
diff --git a/apps/admin/src/components/shell/escape-owner.ts b/apps/admin/src/components/shell/escape-owner.ts
index 910c293..3dd9130 100644
--- a/apps/admin/src/components/shell/escape-owner.ts
+++ b/apps/admin/src/components/shell/escape-owner.ts
@@ -1,11 +1,8 @@
/**
- * Layers that own the Escape key while they are open: the slide-over task panel, a row action menu
- * and a modal dialog. The shell's global "Escape goes home" must yield to them, otherwise pressing
- * Escape with focus on a panel button navigates away and destroys the draft. The create panel is
- * rendered only while open and the dialog host only while a dialog is up, so presence alone is the
- * signal; `aria-hidden` is excluded as a belt-and-braces for a dialog kept mounted while closed.
- * The shell must run this check in the capture phase (see app-shell.tsx): by the bubble phase the
- * layer may already have closed itself and left the DOM.
+ * The shell's "Escape goes home" must yield to an open slide-over, row menu or modal, or Escape on a
+ * panel button navigates away and destroys the draft. These layers render only while open, so presence
+ * is the signal; `aria-hidden` covers a dialog kept mounted while closed. The shell must check in the
+ * capture phase: by the bubble phase the layer may already have closed itself and left the DOM.
*/
export const ESCAPE_OWNER_SELECTOR =
'.panel.open, .row-menu-pop, [role="dialog"][aria-modal="true"]:not([aria-hidden="true"])'
@@ -14,13 +11,8 @@ export function hasEscapeOwner(doc: Pick): boolean {
return doc.querySelector(ESCAPE_OWNER_SELECTOR) !== null
}
-/** Elements whose own Escape/keyboard handling the shell never overrides. */
const EDITABLE_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"])
-/**
- * Whether a keydown should send the shell back home: Escape, off the home page, not inside a text
- * control, and with no open layer that owns the key.
- */
export function shellEscapeGoesHome(opts: {
key: string
page: string
diff --git a/apps/admin/src/components/shell/page-registry.tsx b/apps/admin/src/components/shell/page-registry.tsx
index 1d86a2d..00f0c11 100644
--- a/apps/admin/src/components/shell/page-registry.tsx
+++ b/apps/admin/src/components/shell/page-registry.tsx
@@ -4,7 +4,6 @@ import * as React from "react"
import type { PageId } from "@/store/ui-store"
-
export interface SectionPageProps {
focusId: string | null
}
diff --git a/apps/admin/src/components/shell/toast.tsx b/apps/admin/src/components/shell/toast.tsx
index 8092d1c..a6519be 100644
--- a/apps/admin/src/components/shell/toast.tsx
+++ b/apps/admin/src/components/shell/toast.tsx
@@ -12,9 +12,8 @@ export const ERROR_TOAST_MS = 8000
const TOAST_MS: Record = { ok: SUCCESS_TOAST_MS, error: ERROR_TOAST_MS }
/**
- * Global ephemeral toast, driven by the UI store: `useToast()` after a successful write, and the query
- * client's mutation cache for every failed write. Toasts confirm destructive writes that have no real
- * undo, so the trailing affordance is a dismiss "X", not an "Undo" that would imply a revert.
+ * Toasts confirm writes that have no real undo, so the trailing affordance is a dismiss, not an "Undo"
+ * that would imply a revert.
*
* The status region stays mounted so screen readers announce each new toast, and holds only the message:
* the dismiss button sits beside it, so "Dismiss" is not read as part of the announcement, and exists
diff --git a/apps/admin/src/features/analytics/analytics-charts.tsx b/apps/admin/src/features/analytics/analytics-charts.tsx
index 514e657..52508bd 100644
--- a/apps/admin/src/features/analytics/analytics-charts.tsx
+++ b/apps/admin/src/features/analytics/analytics-charts.tsx
@@ -1,13 +1,7 @@
"use client"
-/**
- * Analytics chart helpers, ported from the design (metrics.jsx BarChart and pages-operations.jsx
- * Spark). Class names + DOM mirror the prototype so they render pixel-faithfully against the ported
- * admin.css (`.barchart*`, `.hub-spark*`). Used by the Analytics cards (pins-per-week + cleanup
- * events use BarChart; Spark is available for compact inline trends).
- */
+// Class names and DOM match the `.barchart*` and `.hub-spark*` rules in admin.css.
-/** A column bar chart with an optional label row; the last bar gets the `now` accent. */
export function BarChart({ values, labels }: { values: number[]; labels?: string[] }) {
const max = Math.max(...values, 1)
return (
@@ -30,7 +24,6 @@ export function BarChart({ values, labels }: { values: number[]; labels?: string
)
}
-/** A compact sparkline (the design's hub spark): min/max-normalized bars, last bar accented. */
export function Spark({
values,
label,
diff --git a/apps/admin/src/features/analytics/analytics-page.test.tsx b/apps/admin/src/features/analytics/analytics-page.test.tsx
index 802c422..c37097c 100644
--- a/apps/admin/src/features/analytics/analytics-page.test.tsx
+++ b/apps/admin/src/features/analytics/analytics-page.test.tsx
@@ -15,6 +15,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"
import { Toast } from "@/components/shell/toast"
import type * as ApiModule from "@/lib/api"
+import { EMPTY_VALUE } from "@/lib/empty-value"
import { AnalyticsPage } from "@/features/analytics/analytics-page"
import { useUiStore } from "@/store/ui-store"
import { apiMock } from "@/test/api-mock"
@@ -397,7 +398,7 @@ describe("AnalyticsPage", () => {
expect(within(c).getByText("Hazard")).toBeInTheDocument()
expect(within(c).getByText("<1h")).toBeInTheDocument()
expect(within(c).getByText("Water")).toBeInTheDocument()
- expect(within(c).getByText(/^\u2014$/)).toBeInTheDocument()
+ expect(within(c).getByText(EMPTY_VALUE)).toBeInTheDocument()
})
it("rounds resolution times to whole hours before splitting them into days", async () => {
@@ -499,7 +500,7 @@ describe("AnalyticsPage", () => {
expect(within(c).getByText("17")).toBeInTheDocument()
expect(within(c).getByText("grace")).toBeInTheDocument()
expect(within(c).getByText("G")).toBeInTheDocument()
- expect(within(c).getByText(/^\u2014$/)).toBeInTheDocument()
+ expect(within(c).getByText(EMPTY_VALUE)).toBeInTheDocument()
expect(within(c).getByText("9")).toBeInTheDocument()
})
diff --git a/apps/admin/src/features/analytics/analytics-page.tsx b/apps/admin/src/features/analytics/analytics-page.tsx
index 4ece72b..2d14fe9 100644
--- a/apps/admin/src/features/analytics/analytics-page.tsx
+++ b/apps/admin/src/features/analytics/analytics-page.tsx
@@ -10,6 +10,7 @@ import {
import { Icons } from "@/components/icons"
import { categoryCssVar } from "@/lib/category"
import { downloadCsv } from "@/lib/csv"
+import { EMPTY_VALUE } from "@/lib/empty-value"
import { PageHead, EmptyState } from "@/components/shared/page-primitives"
import { LoadingState, ErrorState } from "@/components/shared/data-states"
import { BarChart } from "@/features/analytics/analytics-charts"
@@ -27,7 +28,6 @@ import {
import { useToast } from "@/store/ui-store"
import type { SectionPageProps } from "@/components/shell/page-registry"
-
function initials(name: string): string {
const out = name
.split(" ")
@@ -39,7 +39,7 @@ function initials(name: string): string {
}
function humanizeHours(hours: number): string {
- if (hours <= 0) return "—"
+ if (hours <= 0) return EMPTY_VALUE
if (hours < 1) return "<1h"
const total = Math.round(hours)
if (total < 24) return `${total}h`
@@ -183,7 +183,6 @@ export function AnalyticsPage(_props: SectionPageProps) {
- { }
{kpisQuery.isLoading ? (
@@ -203,7 +202,6 @@ export function AnalyticsPage(_props: SectionPageProps) {
)}
@@ -399,7 +389,6 @@ export function AnalyticsPage(_props: SectionPageProps) {
)}
- { }
{initials(c.name)}
{c.name}
- {c.city || "—"}
+ {c.city || EMPTY_VALUE}
diff --git a/apps/admin/src/features/analytics/use-analytics.ts b/apps/admin/src/features/analytics/use-analytics.ts
index d1f20bf..470566e 100644
--- a/apps/admin/src/features/analytics/use-analytics.ts
+++ b/apps/admin/src/features/analytics/use-analytics.ts
@@ -16,17 +16,8 @@ import type {
import { api } from "@/lib/api"
import { queryKeys } from "@/lib/query"
-/**
- * Data hooks for the Analytics section (enumeration 2.G). Each analytics card is an INDEPENDENT read so
- * one failing aggregate does not blank the whole page (each card renders its own loading / error /
- * empty state). All nine endpoints take no arguments (the server scopes the window). These are reads
- * only; analytics has no mutations (the CSV Export is a client-side download in the page).
- *
- * Query keys: reuses the existing registry (analytics.kpis/pinsByWeek/.../retention). No local keys
- * were needed.
- */
+// Each card is its own query so one failing aggregate does not blank the whole page.
-/** GET /admin/analytics/kpis - the KPI strip cells. */
export function useAnalyticsKpis() {
return useQuery({
queryKey: queryKeys.analytics.kpis,
@@ -34,7 +25,6 @@ export function useAnalyticsKpis() {
})
}
-/** GET /admin/analytics/pins-by-week - the 8-week pins trend. */
export function useAnalyticsPinsByWeek() {
return useQuery({
queryKey: queryKeys.analytics.pinsByWeek,
@@ -42,7 +32,6 @@ export function useAnalyticsPinsByWeek() {
})
}
-/** GET /admin/analytics/by-category - per-category report counts + share. */
export function useAnalyticsByCategory() {
return useQuery({
queryKey: queryKeys.analytics.byCategory,
@@ -50,7 +39,6 @@ export function useAnalyticsByCategory() {
})
}
-/** GET /admin/analytics/funnel - pin -> routed -> acknowledged -> resolved. */
export function useAnalyticsFunnel() {
return useQuery({
queryKey: queryKeys.analytics.funnel,
@@ -58,7 +46,6 @@ export function useAnalyticsFunnel() {
})
}
-/** GET /admin/analytics/coverage - mapped vs needs-mapping jurisdictions. */
export function useAnalyticsCoverage() {
return useQuery({
queryKey: queryKeys.analytics.coverage,
@@ -66,7 +53,6 @@ export function useAnalyticsCoverage() {
})
}
-/** GET /admin/analytics/resolution-by-category - median resolution hours per category. */
export function useAnalyticsResolutionByCategory() {
return useQuery({
queryKey: queryKeys.analytics.resolutionByCategory,
@@ -74,7 +60,6 @@ export function useAnalyticsResolutionByCategory() {
})
}
-/** GET /admin/analytics/events - cleanup events stats + 8-month trend. */
export function useAnalyticsEvents() {
return useQuery({
queryKey: queryKeys.analytics.events,
@@ -82,7 +67,6 @@ export function useAnalyticsEvents() {
})
}
-/** GET /admin/analytics/top-jurisdictions - top jurisdictions by pin volume. */
export function useAnalyticsTopJurisdictions() {
return useQuery({
queryKey: queryKeys.analytics.topJurisdictions,
@@ -90,7 +74,6 @@ export function useAnalyticsTopJurisdictions() {
})
}
-/** GET /admin/analytics/top-contributors - top contributors by reports + cleanups. */
export function useAnalyticsTopContributors() {
return useQuery({
queryKey: queryKeys.analytics.topContributors,
diff --git a/apps/admin/src/features/auth/operator-login.tsx b/apps/admin/src/features/auth/operator-login.tsx
index 58d74a7..320faaa 100644
--- a/apps/admin/src/features/auth/operator-login.tsx
+++ b/apps/admin/src/features/auth/operator-login.tsx
@@ -5,19 +5,8 @@ import * as React from "react"
import { useOperatorBootstrap, useOperatorSession } from "@/hooks/use-admin-auth"
import { SOURCE } from "@/lib/source"
-/**
- * Full-page operator gate (Cloudflare Access SSO, doc 16; same-origin deployment), styled with the admin
- * design system. The dashboard renders this whenever there is no authenticated operator session (see
- * providers.tsx).
- *
- * Authentication is delegated to Cloudflare Access and the user has already passed it to load this SPA
- * (the whole origin is Access-gated). The AuthHydrator establishes the operator session on mount. This
- * screen covers the two states where that did not produce a session:
- * - anonymous: the exchange could not be completed (Access misconfigured / backend unreachable /
- * transient). Offer a retry.
- * - forbidden: Access authenticated the user but their email is not on the operator allowlist (a clean
- * 403). Terminal - show a clear not-authorized message.
- */
+// No credential form: the whole origin is Access-gated, so anyone seeing this has already signed in to
+// Access and only the operator session exchange failed or was refused.
export function OperatorLogin() {
const { status } = useOperatorSession()
const bootstrap = useOperatorBootstrap()
diff --git a/apps/admin/src/features/discovery/discovery-page.test.tsx b/apps/admin/src/features/discovery/discovery-page.test.tsx
index f305cb9..1006ae7 100644
--- a/apps/admin/src/features/discovery/discovery-page.test.tsx
+++ b/apps/admin/src/features/discovery/discovery-page.test.tsx
@@ -11,6 +11,7 @@ import type {
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"
@@ -155,7 +156,7 @@ describe("DiscoveryPage", () => {
const alert = await screen.findByRole("alert")
expect(alert).toHaveTextContent("Could not load this")
expect(alert).toHaveTextContent("directory is down")
- expect(chip(/^Needs mapping/)).toHaveTextContent(/\u2014/)
+ 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" }))
diff --git a/apps/admin/src/features/discovery/discovery-page.tsx b/apps/admin/src/features/discovery/discovery-page.tsx
index 5db1d09..093a075 100644
--- a/apps/admin/src/features/discovery/discovery-page.tsx
+++ b/apps/admin/src/features/discovery/discovery-page.tsx
@@ -14,6 +14,7 @@ import {
import { Icons } from "@/components/icons"
import { errorMessage } from "@/lib/error-messages"
+import { EMPTY_VALUE } from "@/lib/empty-value"
import {
REPORT_CATEGORIES,
categoryLabel,
@@ -58,7 +59,6 @@ const BoundaryMap = dynamic(
import { useToast } from "@/store/ui-store"
import type { SectionPageProps } from "@/components/shell/page-registry"
-
const LAYER_LABEL: Record = {
place: "City",
county: "County",
@@ -70,19 +70,18 @@ const LAYER_LABEL: Record = {
const UNMAPPED_GEOID = "__unmapped__"
function fmtRouted(iso: string | null): string {
- if (!iso) return "—"
+ if (!iso) return EMPTY_VALUE
const d = new Date(iso)
- if (Number.isNaN(d.getTime())) return "—"
+ if (Number.isNaN(d.getTime())) return EMPTY_VALUE
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 "—"
+ if (!iso) return EMPTY_VALUE
const then = new Date(iso).getTime()
- if (Number.isNaN(then)) return "—"
+ if (Number.isNaN(then)) return EMPTY_VALUE
const diff = Date.now() - then
if (diff < 60 * 1000) return "just now"
const mins = Math.floor(diff / (60 * 1000))
@@ -98,7 +97,6 @@ function fmtAge(iso: string | null): string {
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()
@@ -415,7 +413,7 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
Bounced
@@ -433,7 +431,6 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
{filledCount} of {REPORT_TYPES.length} contacts set
@@ -676,7 +668,7 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
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.{" "}
+ 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/discovery-ui-state.test.ts b/apps/admin/src/features/discovery/discovery-ui-state.test.ts
index 25ebaad..96943dd 100644
--- a/apps/admin/src/features/discovery/discovery-ui-state.test.ts
+++ b/apps/admin/src/features/discovery/discovery-ui-state.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"
+import { EMPTY_VALUE } from "@/lib/empty-value"
import {
getCountDisplay,
getJurisdictionSort,
@@ -16,7 +17,7 @@ describe("Jurisdictions UI state", () => {
it("shows loading and unavailable count labels instead of false zeroes", () => {
expect(getCountDisplay({ count: null, isLoading: true, isError: false })).toBe("Loading…")
- expect(getCountDisplay({ count: null, isLoading: false, isError: true })).toBe("\u2014")
+ expect(getCountDisplay({ count: null, isLoading: false, isError: true })).toBe(EMPTY_VALUE)
expect(getCountDisplay({ count: 0, isLoading: false, isError: false })).toBe(0)
})
})
diff --git a/apps/admin/src/features/discovery/discovery-ui-state.ts b/apps/admin/src/features/discovery/discovery-ui-state.ts
index 918d8b0..aa2eba7 100644
--- a/apps/admin/src/features/discovery/discovery-ui-state.ts
+++ b/apps/admin/src/features/discovery/discovery-ui-state.ts
@@ -1,3 +1,5 @@
+import { EMPTY_VALUE } from "@/lib/empty-value"
+
export type JurisdictionFilter = "all" | "attention" | "clear"
export type JurisdictionSort = "pop" | "reports" | "oldest"
@@ -16,9 +18,9 @@ export function getCountDisplay({
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
}
diff --git a/apps/admin/src/features/discovery/use-discovery.ts b/apps/admin/src/features/discovery/use-discovery.ts
index dd3d415..51312b5 100644
--- a/apps/admin/src/features/discovery/use-discovery.ts
+++ b/apps/admin/src/features/discovery/use-discovery.ts
@@ -26,17 +26,6 @@ import { errorMessage } from "@/lib/error-messages"
import { queryKeys } from "@/lib/query"
import { partialSaveMessage, type SavedExtras } from "@/features/discovery/discovery-payloads"
-/**
- * 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),
@@ -44,7 +33,6 @@ 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 ?? ""),
@@ -53,19 +41,14 @@ export function useDiscoveryTask(id: string | null) {
})
}
-/** 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
/**
- * 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({
@@ -82,10 +65,8 @@ export function useJurisdictionDirectory(params: JurisdictionDirectoryParams) {
}
/**
- * 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({
@@ -99,7 +80,7 @@ export function useJurisdictionGeometry(geoid: string | null) {
const BOUNDARY_KEY = queryKeys.jurisdictions.geometry("").slice(0, -1)
-/** Invalidate every discovery/jurisdiction view plus the home aggregates after a write. */
+/** The home aggregates count saved contacts and flags, so every discovery write refreshes them too. */
function invalidateDiscovery(qc: ReturnType) {
return Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.discovery.all }),
@@ -113,7 +94,6 @@ function invalidateDiscovery(qc: ReturnType) {
])
}
-/** POST /admin/discovery/:id/notes - append an operator note to a discovery task. */
export function useAddDiscoveryNote() {
const qc = useQueryClient()
return useMutation({
@@ -126,7 +106,6 @@ export function useAddDiscoveryNote() {
})
}
-/** POST /admin/discovery/:id/flag - flag a discovery task / jurisdiction for review. */
export function useFlagDiscovery() {
const qc = useQueryClient()
return useMutation({
@@ -139,7 +118,6 @@ export function useFlagDiscovery() {
})
}
-/** POST /admin/discovery/:id/draft - save the routing-contact draft without routing. */
export function useSaveDiscoveryDraft() {
const qc = useQueryClient()
return useMutation({
@@ -160,7 +138,6 @@ export interface SaveContactsVariables {
savedFirst?: SavedExtras
}
-/** POST /admin/jurisdictions/:geoid/contacts - the core "Save contacts" action. */
export function useSaveJurisdictionContacts() {
const qc = useQueryClient()
return useMutation({
@@ -199,11 +176,6 @@ function patchSuccessMessage({ request, org, action }: PatchJurisdictionVariable
}
}
-/**
- * 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 function usePatchJurisdiction() {
const qc = useQueryClient()
return useMutation({
diff --git a/apps/admin/src/features/events/events-page.tsx b/apps/admin/src/features/events/events-page.tsx
index ceaa6bd..0fc8937 100644
--- a/apps/admin/src/features/events/events-page.tsx
+++ b/apps/admin/src/features/events/events-page.tsx
@@ -42,7 +42,6 @@ import { useReportListInfinite } from "@/features/reports/use-reports"
import { useNav } from "@/store/ui-store"
import type { SectionPageProps } from "@/components/shell/page-registry"
-
const LeafletMap = dynamic(() => import("@/components/map/leaflet-map").then((m) => m.LeafletMap), {
ssr: false,
loading: () => ,
@@ -464,7 +463,6 @@ function EventDetail({ eventId }: { eventId: string }) {
- { }
About
@@ -486,7 +484,6 @@ function EventDetail({ eventId }: { eventId: string }) {
)
diff --git a/apps/admin/src/features/inbox/use-inbox.ts b/apps/admin/src/features/inbox/use-inbox.ts
index 13f2c92..8ea7bd4 100644
--- a/apps/admin/src/features/inbox/use-inbox.ts
+++ b/apps/admin/src/features/inbox/use-inbox.ts
@@ -19,7 +19,6 @@ import { attachmentRefreshInterval } from "@/features/inbox/attachments"
import { api } from "@/lib/api"
import { queryKeys } from "@/lib/query"
-
export function useInboxList(params: InboxListQuery) {
return useQuery({
queryKey: queryKeys.inbox.page(params),
diff --git a/apps/admin/src/features/mail/forward-template-modal.tsx b/apps/admin/src/features/mail/forward-template-modal.tsx
index 90fda8d..e52f649 100644
--- a/apps/admin/src/features/mail/forward-template-modal.tsx
+++ b/apps/admin/src/features/mail/forward-template-modal.tsx
@@ -153,7 +153,7 @@ function ForwardTemplateEditor({
key={v.token}
type="button"
className="tpl-chip"
- title={`${v.label} — ${v.description}`}
+ title={`${v.label}: ${v.description}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertToken(v.token)}
>
diff --git a/apps/admin/src/features/mail/mail-page.tsx b/apps/admin/src/features/mail/mail-page.tsx
index 00b9e7e..37783d2 100644
--- a/apps/admin/src/features/mail/mail-page.tsx
+++ b/apps/admin/src/features/mail/mail-page.tsx
@@ -54,9 +54,9 @@ import { MAIL_STATUS_CLS, tsTitle } from "@/features/mail/mail-presentation"
import { WithheldReplyNote } from "@/features/mail/withheld-reply-note"
import { useNav, useToast } from "@/store/ui-store"
import { errorMessage } from "@/lib/error-messages"
+import { EMPTY_VALUE } from "@/lib/empty-value"
import type { SectionPageProps } from "@/components/shell/page-registry"
-
type Folder = "outreach" | "inbox"
const FOLDERS: readonly Folder[] = ["outreach", "inbox"]
@@ -120,7 +120,7 @@ function correspondent(sel: MailThreadDTO): string {
const m = sel.messages[i]!
if (m.dir === "out" && m.to) return m.to
}
- return sel.to || "—"
+ return sel.to || EMPTY_VALUE
}
interface ComposeModalProps {
@@ -423,7 +423,7 @@ function MailReader({ threadId, eventId = null }: { threadId: string; eventId?:
{sel.status === "bounced" && (
- Hard bounce — the address rejected delivery. Try a different contact or the city's
+ Hard bounce: the address rejected delivery. Try a different contact or the city's
reporting form.
diff --git a/apps/admin/src/features/mail/use-mail.ts b/apps/admin/src/features/mail/use-mail.ts
index d4f155a..65da5d5 100644
--- a/apps/admin/src/features/mail/use-mail.ts
+++ b/apps/admin/src/features/mail/use-mail.ts
@@ -30,7 +30,6 @@ import { PUBLISH_TOAST } from "@/features/mail/mail-presentation"
import { api } from "@/lib/api"
import { queryKeys } from "@/lib/query"
-
export function useMailList(params: MailListQuery) {
return useQuery({
queryKey: queryKeys.mail.page(params),
diff --git a/apps/admin/src/features/moderation/gov-claim-presentation.ts b/apps/admin/src/features/moderation/gov-claim-presentation.ts
index 40d3ee8..1b9f407 100644
--- a/apps/admin/src/features/moderation/gov-claim-presentation.ts
+++ b/apps/admin/src/features/moderation/gov-claim-presentation.ts
@@ -10,26 +10,23 @@ import {
import { errorMessage } from "@/lib/error-messages"
-/** Pill treatment per claim lifecycle status. */
export const GOV_CLAIM_STATUS_VIEW: Record = {
pending: { cls: "status-new", label: "Pending" },
approved: { cls: "status-ok", label: "Approved" },
rejected: { cls: "status-flag", label: "Rejected" },
}
-/** Pill treatment per verification check state. */
export const GOV_CHECK_STATUS_VIEW: Record = {
verified: { cls: "status-ok", label: "Verified" },
pending: { cls: "status-new", label: "Pending" },
}
-/** How the applicant reached us. */
export const GOV_METHOD_LABEL: Record = {
email: "Emailed us",
cold_outreach: "Cold outreach",
}
-/** The three verification checks, in the order the detail panel lists them. */
+/** In the order the detail panel lists them. */
export const GOV_CHECKS: readonly GovVerificationCheck[] = ["linkedin", "directory", "callback"]
export function govCheckLabel(check: GovVerificationCheck): string {
@@ -74,7 +71,7 @@ export function govClaimApproveErrorMessage(error: unknown): string {
error,
{
[ErrorCode.FORBIDDEN]:
- "That contact email belongs to an operator account. Operator accounts are managed through ADMIN_EMAILS and cannot be re-roled here — the applicant needs a different address.",
+ "That contact email belongs to an operator account. Operator accounts are managed through ADMIN_EMAILS and cannot be re-roled here, so the applicant needs a different address.",
},
{
fields: {
diff --git a/apps/admin/src/features/moderation/gov-claims-views.tsx b/apps/admin/src/features/moderation/gov-claims-views.tsx
index 163dd63..5d8ee3b 100644
--- a/apps/admin/src/features/moderation/gov-claims-views.tsx
+++ b/apps/admin/src/features/moderation/gov-claims-views.tsx
@@ -9,6 +9,7 @@ import { confirmDialog, promptDialog } from "@/components/shared/dialog"
import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation"
import { isNotFound } from "@/lib/api"
import { isHttpsUrl } from "@/lib/external-url"
+import { EMPTY_VALUE } from "@/lib/empty-value"
import {
GOV_CHECKS,
GOV_CHECK_STATUS_VIEW,
@@ -264,7 +265,7 @@ export function GovClaimDetail({
))}
- Verify the applicant before approving — approval grants a government role on the
+ Verify the applicant before approving: approval grants a government role on the
account behind the contact email.
@@ -303,7 +304,7 @@ export function GovClaimDetail({
Decision
{item.kind === "appeal" ? (
@@ -789,14 +781,14 @@ export function ModerationPage({ focusId }: SectionPageProps) {
subtitle={
queue ? (
- The moderation queue — citizen content reports (the in-app “Report” button)
+ The moderation queue: citizen content reports (the in-app “Report” button)
plus held media, coordinated-report clusters, and appeals. Review the signals, then
approve, remove, hold, or decide the appeal.
) : (
Government staff asking for access to their jurisdiction. Verify who they are, then
- approve — which provisions a government role on their account — or reject with a reason.
+ approve (which provisions a government role on their account) or reject with a reason.
)
}
diff --git a/apps/admin/src/features/moderation/use-gov-claims.ts b/apps/admin/src/features/moderation/use-gov-claims.ts
index b197fff..cc28bb1 100644
--- a/apps/admin/src/features/moderation/use-gov-claims.ts
+++ b/apps/admin/src/features/moderation/use-gov-claims.ts
@@ -18,13 +18,6 @@ import {
govClaimApproveErrorMessage,
} from "@/features/moderation/gov-claim-presentation"
-/**
- * Data hooks for the gov-provisioning queue (GET/POST /admin/gov-claims*). An operator verifies the
- * applicant's LinkedIn / municipal directory / phone callback, then approves — which provisions the
- * government role on the contact email's account and links the jurisdiction — or rejects with a reason.
- * Approve therefore also invalidates the users caches, since it changes an account's role.
- */
-
export function useGovClaimListInfinite(params: GovClaimListQuery) {
return useInfiniteQuery({
queryKey: queryKeys.govClaims.list(params),
@@ -80,6 +73,7 @@ export function useApproveGovClaim() {
successMessage: (_res: unknown, { claim }: GovClaimDecision) =>
`${claim.name} approved · government role provisioned`,
},
+ // Approval provisions a government role on the contact email's account, so the users caches go stale.
onSuccess: (_res, { request: { id } }) =>
Promise.all([
invalidateGovClaims(qc, id),
diff --git a/apps/admin/src/features/orgs/create-org-panel.tsx b/apps/admin/src/features/orgs/create-org-panel.tsx
index dd42e54..0cee38b 100644
--- a/apps/admin/src/features/orgs/create-org-panel.tsx
+++ b/apps/admin/src/features/orgs/create-org-panel.tsx
@@ -34,8 +34,8 @@ const VERIFICATION_OPTIONS: { value: "" | OrgVerificationKind; label: string }[]
]
/**
- * The "New organization" slide-over (`.panel`). The form only exists while the panel is open, so
- * closing it discards the draft, the submitted flag and every error: reopening always starts clean.
+ * The form only exists while the panel is open, so closing it discards the draft, the submitted flag
+ * and every error: reopening always starts clean.
*/
export function CreateOrgPanel({
open,
@@ -50,7 +50,6 @@ export function CreateOrgPanel({
return
}
-/** The local checks the create form runs before a request: the profile, the owner and the reason. */
function validateCreate(
draft: OrgProfileDraft,
owner: PickedUser | null,
@@ -63,11 +62,10 @@ function validateCreate(
}
/**
- * Everything the create request needs lives here: the profile fields, the owner picker (resolves a
- * person to a userId), the verification shortcut for operator-onboarded partners (DECISIONS §32) and
- * the audit reason. Local validation errors are recomputed live once the operator has tried to
- * submit; server errors (slug conflict, VALIDATION.fields) are held apart and cleared per field as
- * that field changes, so a message never outlives the value it was about.
+ * The verification shortcut is for operator-onboarded partners (DECISIONS §32). Local validation
+ * errors are recomputed live once the operator has tried to submit; server errors (slug conflict,
+ * VALIDATION.fields) are held apart and cleared per field as that field changes, so a message never
+ * outlives the value it was about.
*/
function CreateOrgSlideOver({
onClose,
@@ -254,7 +252,7 @@ function CreateOrgSlideOver({
{verifiedKind === ""
? "The organization can apply for verification itself from its settings."
- : "Created already verified — no evidence round-trip. Use for partners you onboard directly (a city department, a known nonprofit)."}
+ : "Created already verified, with no evidence round-trip. Use for partners you onboard directly (a city department, a known nonprofit)."}
diff --git a/apps/admin/src/features/orgs/members-panel.tsx b/apps/admin/src/features/orgs/members-panel.tsx
index b435ae7..49ac24d 100644
--- a/apps/admin/src/features/orgs/members-panel.tsx
+++ b/apps/admin/src/features/orgs/members-panel.tsx
@@ -146,7 +146,6 @@ function MemberRow({
const flipUp = rect.bottom + 220 > window.innerHeight
setMenuPos(flipUp ? { bottom: window.innerHeight - rect.top + 4, right } : { top: rect.bottom + 4, right })
}
- /** Close the menu; `returnFocus` hands focus back to the trigger (keyboard dismissals). */
const closeMenu = React.useCallback((returnFocus: boolean) => {
setMenuPos(null)
if (returnFocus) triggerRef.current?.focus({ preventScroll: true })
diff --git a/apps/admin/src/features/orgs/org-events-panel.tsx b/apps/admin/src/features/orgs/org-events-panel.tsx
index 4a0537c..0403184 100644
--- a/apps/admin/src/features/orgs/org-events-panel.tsx
+++ b/apps/admin/src/features/orgs/org-events-panel.tsx
@@ -18,7 +18,6 @@ const WHEN_OPTIONS: { value: AdminOrgEventWhen; label: string }[] = [
{ value: "all", label: "All" },
]
-/** Events hosted under the org's name; a row opens the event in the Events section. */
export function OrgEventsPanel({ org }: { org: AdminOrgDTO }) {
const [when, setWhen] = React.useState("upcoming")
const q = useOrgEventsInfinite(org.id, when)
diff --git a/apps/admin/src/features/orgs/org-form-fields.tsx b/apps/admin/src/features/orgs/org-form-fields.tsx
index cce2f7b..831b121 100644
--- a/apps/admin/src/features/orgs/org-form-fields.tsx
+++ b/apps/admin/src/features/orgs/org-form-fields.tsx
@@ -183,9 +183,9 @@ export function LogoField({
}
/**
- * The profile fields shared by "New organization" and "Edit profile". In create mode the slug follows
- * the name until the operator edits it by hand; in edit mode the slug never auto-changes and a change
- * is called out because it breaks every existing link to the public page.
+ * In create mode the slug follows the name until the operator edits it by hand; in edit mode the slug
+ * never auto-changes and a change is called out because it breaks every existing link to the public
+ * page.
*/
export function OrgProfileFields({
draft,
diff --git a/apps/admin/src/features/orgs/org-form.ts b/apps/admin/src/features/orgs/org-form.ts
index 846904d..e14fc5c 100644
--- a/apps/admin/src/features/orgs/org-form.ts
+++ b/apps/admin/src/features/orgs/org-form.ts
@@ -19,7 +19,7 @@ import { slugProblem } from "@/features/orgs/org-slug"
export { MAX_ORG_DESCRIPTION, MAX_ORG_NAME, SOCIAL_PLATFORMS }
export type { SocialPlatform }
-/** The editable profile fields, as the form holds them (strings; "" means empty). */
+/** Form state: text fields are strings and "" means empty. */
export interface OrgProfileDraft {
name: string
slug: string
@@ -77,7 +77,6 @@ export function draftFromOrg(org: AdminOrgDTO): OrgProfileDraft {
}
}
-/** The social links object the API accepts, or null when every handle is blank. */
export function socialLinksFromDraft(social: Record): SocialLinks | null {
const out: SocialLinks = {}
let any = false
@@ -91,10 +90,7 @@ export function socialLinksFromDraft(social: Record): So
return any ? out : null
}
-/**
- * Client-side validation mirroring the create/update request schemas, so the operator sees inline
- * errors before a round-trip. Returns an empty object when the draft is acceptable.
- */
+/** Mirrors the create/update request schemas so the operator sees inline errors before a round-trip. */
export function validateProfileDraft(draft: OrgProfileDraft): OrgProfileErrors {
const errors: OrgProfileErrors = {}
const name = draft.name.trim()
@@ -199,10 +195,10 @@ export function buildUpdateRequest(
}
/**
- * Map a failed create/update to inline field errors. A CONFLICT is the slug (the only unique field an
- * operator supplies) — but only when the request actually carried a slug, which an edit that leaves
- * the slug alone does not; a VALIDATION error carries `fields` keyed by request field. Anything else
- * returns an empty object so the caller falls back to the error toast.
+ * A CONFLICT is the slug (the only unique field an operator supplies), but only when the request
+ * actually carried a slug, which an edit that leaves the slug alone does not. A VALIDATION error
+ * carries `fields` keyed by request field. Anything else returns an empty object so the caller falls
+ * back to the error toast.
*/
export function fieldErrorsFromError(raw: unknown, request?: { slug?: string }): OrgProfileErrors {
if (!(raw instanceof Error)) return {}
@@ -235,9 +231,8 @@ export function fieldErrorsFromError(raw: unknown, request?: { slug?: string }):
}
/**
- * Drop the server-reported errors for every profile field whose value changed between two drafts:
- * the operator is fixing that field, so the stale server message must not stick to it. Returns the
- * same object when nothing was cleared, so callers can skip a state update.
+ * A changed field is one the operator is fixing, so its stale server message must not stick to it.
+ * Returns the same object when nothing was cleared, so callers can skip a state update.
*/
export function clearChangedFieldErrors(
errors: OrgProfileErrors,
@@ -258,7 +253,6 @@ export function clearChangedFieldErrors(
return out
}
-/** Keep only the errors under `keys` — the fields a given form actually renders. */
export function pickFieldErrors(
errors: OrgProfileErrors,
keys: readonly (keyof OrgProfileErrors)[],
@@ -278,7 +272,6 @@ const PROFILE_EDITOR_FIELDS: readonly (keyof OrgProfileErrors)[] = [
...SOCIAL_PLATFORMS,
]
-/** The server errors the profile editor can show next to a field, for a failed update. */
export function updateFieldErrors(raw: unknown, request: AdminUpdateOrgRequest): OrgProfileErrors {
return pickFieldErrors(fieldErrorsFromError(raw, request), PROFILE_EDITOR_FIELDS)
}
diff --git a/apps/admin/src/features/orgs/org-members.ts b/apps/admin/src/features/orgs/org-members.ts
index f3edade..c2b19a3 100644
--- a/apps/admin/src/features/orgs/org-members.ts
+++ b/apps/admin/src/features/orgs/org-members.ts
@@ -14,11 +14,7 @@ export const ORG_ROLE_PILL: Record = {
member: "priority-low",
}
-/**
- * The roles a member can be moved to from their current one. Every role but the current one is a
- * legal target — including `owner`, which is an ownership transfer (DECISIONS §32) — so the menu
- * offers all the others.
- */
+// `owner` is a legal target too: choosing it is an ownership transfer (DECISIONS §32).
export function roleTargets(current: OrganizationMemberRole): OrganizationMemberRole[] {
return ORG_ROLES.filter((r) => r !== current)
}
diff --git a/apps/admin/src/features/orgs/org-slug.ts b/apps/admin/src/features/orgs/org-slug.ts
index b2ad141..0ab912c 100644
--- a/apps/admin/src/features/orgs/org-slug.ts
+++ b/apps/admin/src/features/orgs/org-slug.ts
@@ -3,9 +3,8 @@ import { ORG_SLUG_MAX, ORG_SLUG_MIN, OrgSlugSchema } from "@civfix/shared"
export const PUBLIC_ORG_ORIGIN = "https://civfix.org"
/**
- * Derive a URL slug from an organization name: lowercase, ASCII-folded, non-alphanumerics collapsed
- * into single hyphens, trimmed to ORG_SLUG_MAX without leaving a dangling hyphen. May return a string
- * shorter than ORG_SLUG_MIN (e.g. for "LA"); the caller validates with slugProblem before submitting.
+ * May return a string shorter than ORG_SLUG_MIN (e.g. for "LA"); the caller validates with
+ * slugProblem before submitting.
*/
export function deriveSlug(name: string): string {
// NFKD splits accented letters into base + combining mark; \p{M} drops the marks.
@@ -20,9 +19,8 @@ export function deriveSlug(name: string): string {
}
/**
- * Human-readable reason a slug is not acceptable, or null when OrgSlugSchema accepts it. The copy
- * mirrors the schema (3–40 chars, lowercase letters/digits, single hyphens between groups) so the
- * live hint matches what the server would reject.
+ * The copy mirrors OrgSlugSchema (3–40 chars, lowercase letters/digits, single hyphens between groups)
+ * so the live hint matches what the server would reject.
*/
export function slugProblem(slug: string): string | null {
const value = slug.trim()
@@ -36,7 +34,6 @@ export function slugProblem(slug: string): string | null {
return OrgSlugSchema.safeParse(value).success ? null : "Not a valid slug."
}
-/** The public organization page for a slug (opened in a new tab from the console). */
export function publicOrgUrl(slug: string): string {
return `${PUBLIC_ORG_ORIGIN}/orgs/${encodeURIComponent(slug)}`
}
diff --git a/apps/admin/src/features/orgs/orgs-filters.ts b/apps/admin/src/features/orgs/orgs-filters.ts
index 3b86fda..33259e7 100644
--- a/apps/admin/src/features/orgs/orgs-filters.ts
+++ b/apps/admin/src/features/orgs/orgs-filters.ts
@@ -41,7 +41,6 @@ export function orgListParams(filter: string, q?: string): AdminOrgListQuery {
return { ...params, verified: filter }
}
-/** The chip count for a filter, read from the page-one `counts` (absent facets stay blank). */
export function orgFilterCount(
counts: AdminOrgCounts | undefined,
filter: OrgFilter,
diff --git a/apps/admin/src/features/orgs/orgs-page.tsx b/apps/admin/src/features/orgs/orgs-page.tsx
index 82df2a7..f651f2e 100644
--- a/apps/admin/src/features/orgs/orgs-page.tsx
+++ b/apps/admin/src/features/orgs/orgs-page.tsx
@@ -163,7 +163,7 @@ function OrgDetail({
title: suspended ? `Restore ${org.name}?` : `Suspend ${org.name}?`,
body: suspended
? "Lifting the suspension restores exactly what was there: verification, members and profile settings are untouched. The reason is written to the audit log."
- : "A suspended organization keeps its data and members, but every write under its name — events, broadcasts, invites — is refused until it is restored. Its public page shows a notice. The reason is written to the audit log.",
+ : "A suspended organization keeps its data and members, but every write under its name (events, broadcasts, invites) is refused until it is restored. Its public page shows a notice. The reason is written to the audit log.",
label: "Reason (required)",
placeholder: suspended
? "Resolved after the org replaced its contact…"
@@ -338,7 +338,6 @@ export function OrgsPage({ focusId }: SectionPageProps) {
const pickFilter = (next: string) => {
setFilter(next)
- // The verification queue opens on the decision, everything else on the profile.
if (next === "pending") setTab("verification")
else if (tab === "verification") setTab("profile")
}
diff --git a/apps/admin/src/features/orgs/profile-editor.test.tsx b/apps/admin/src/features/orgs/profile-editor.test.tsx
index 569d7a6..886b821 100644
--- a/apps/admin/src/features/orgs/profile-editor.test.tsx
+++ b/apps/admin/src/features/orgs/profile-editor.test.tsx
@@ -5,6 +5,7 @@ import { AppError, ErrorCode, type AdminOrgDTO } from "@civfix/shared"
import { describe, expect, it, onTestFinished, vi } from "vitest"
import type * as ApiModule from "@/lib/api"
+import { EMPTY_VALUE } from "@/lib/empty-value"
import { apiMock } from "@/test/api-mock"
import { renderWithQuery } from "@/test/render"
import { makeQueryClient } from "@/lib/query"
@@ -137,8 +138,8 @@ describe("ProfilePanel facts", () => {
it("shows the same placeholder for an empty website and a missing donation link", () => {
renderWithQuery()
- expect(factValue("Website")).toHaveTextContent(/^\u2014$/)
- expect(factValue("Donation link")).toHaveTextContent(/^\u2014$/)
+ expect(factValue("Website").textContent).toBe(EMPTY_VALUE)
+ expect(factValue("Donation link").textContent).toBe(EMPTY_VALUE)
})
it("shows a donation link that is not https as plain text rather than as missing", () => {
diff --git a/apps/admin/src/features/orgs/profile-panel.tsx b/apps/admin/src/features/orgs/profile-panel.tsx
index 650826a..720bab6 100644
--- a/apps/admin/src/features/orgs/profile-panel.tsx
+++ b/apps/admin/src/features/orgs/profile-panel.tsx
@@ -13,6 +13,7 @@ import { promptDialog } from "@/components/shared/dialog"
import { formatDate, formatDateTime } from "@/lib/dates"
import { isHttpsUrl } from "@/lib/external-url"
import { orgStatusView } from "@/lib/org-status"
+import { EMPTY_VALUE } from "@/lib/empty-value"
import {
buildUpdateRequest,
clearChangedFieldErrors,
@@ -28,7 +29,6 @@ import { useUpdateOrg } from "@/features/orgs/use-orgs"
import { ORG_KIND_LABEL } from "@/features/orgs/org-verification"
import { useNav } from "@/store/ui-store"
-/** Read view of the org profile with an inline edit mode (adminUpdateOrg, reason prompted on save). */
export function ProfilePanel({ org }: { org: AdminOrgDTO }) {
const [editing, setEditing] = React.useState(false)
return editing ? (
@@ -47,7 +47,7 @@ function UrlFact({ url }: { url: string | null | undefined }) {
)
}
- return <>{url || "\u2014"}>
+ return <>{url || EMPTY_VALUE}>
}
function ProfileView({ org, onEdit }: { org: AdminOrgDTO; onEdit: () => void }) {
@@ -80,7 +80,7 @@ function ProfileView({ org, onEdit }: { org: AdminOrgDTO; onEdit: () => void })
Website
@@ -98,7 +98,7 @@ function ProfileView({ org, onEdit }: { org: AdminOrgDTO; onEdit: () => void })
Social
{socials.length === 0 ? (
- "—"
+ EMPTY_VALUE
) : (
{socials.map((p) => (
diff --git a/apps/admin/src/features/orgs/use-orgs.ts b/apps/admin/src/features/orgs/use-orgs.ts
index ea31bc3..f0f48a1 100644
--- a/apps/admin/src/features/orgs/use-orgs.ts
+++ b/apps/admin/src/features/orgs/use-orgs.ts
@@ -31,7 +31,7 @@ import { uploadOrgLogo, type LogoFileFacts } from "@/features/orgs/org-logo-uplo
import { ORG_ROLE_LABEL } from "@/features/orgs/org-members"
import { ORG_KIND_LABEL } from "@/features/orgs/org-verification"
-/** Every organization (adminListOrgs), keyset-paged; page one carries the chip `counts`. */
+/** Page one carries the chip `counts`. */
export function useOrgsInfinite(params: AdminOrgListQuery) {
return useInfiniteQuery({
queryKey: queryKeys.orgs.list(params),
diff --git a/apps/admin/src/features/orgs/user-picker.tsx b/apps/admin/src/features/orgs/user-picker.tsx
index 22ba3f4..7e93189 100644
--- a/apps/admin/src/features/orgs/user-picker.tsx
+++ b/apps/admin/src/features/orgs/user-picker.tsx
@@ -19,7 +19,6 @@ import { errorMessage } from "@/lib/error-messages"
const RESULT_ROWS = 8
const API_MAX_LIMIT = 100
-/** The minimum a picked user needs to render: id, name, handle (avatar optional). */
export interface PickedUser {
id: string
name: string
@@ -42,9 +41,8 @@ export function PickedUserAvatar({ user, size = 28 }: { user: PickedUser; size?:
}
/**
- * Search-and-pick a user by name, handle or city through the admin users list. Once picked, the
- * control collapses to the chosen user with a "Change" affordance so the form keeps a stable height.
- * Ownership is assigned by id (DECISIONS §32): the handle is only how the operator finds the person.
+ * Once picked, the control collapses to the chosen user so the form keeps a stable height. Ownership
+ * is assigned by id (DECISIONS §32); the handle is only how the operator finds the person.
*/
export function UserPicker({
value,
@@ -63,7 +61,6 @@ export function UserPicker({
autoFocus?: boolean
/** Id of the form's visible label for this field; replaces the built-in search label. */
labelledBy?: string
- /** Id of the form's error or hint for this field. */
describedBy?: string
}) {
const [query, setQuery] = React.useState("")
diff --git a/apps/admin/src/features/orgs/verification-panel.tsx b/apps/admin/src/features/orgs/verification-panel.tsx
index 10a8a02..800306a 100644
--- a/apps/admin/src/features/orgs/verification-panel.tsx
+++ b/apps/admin/src/features/orgs/verification-panel.tsx
@@ -8,6 +8,7 @@ import { confirmDialog, promptDialog } from "@/components/shared/dialog"
import { formatDate, formatDateTime } from "@/lib/dates"
import { isHttpsUrl } from "@/lib/external-url"
import { orgStatusView } from "@/lib/org-status"
+import { EMPTY_VALUE } from "@/lib/empty-value"
import { EvidenceList } from "@/features/orgs/evidence-list"
import { ORG_KIND_LABEL, canDecideVerification } from "@/features/orgs/org-verification"
import { useAdminOrg, useDecideOrgVerification } from "@/features/orgs/use-orgs"
@@ -90,7 +91,7 @@ export function VerificationPanel({ orgId }: { orgId: string }) {
diff --git a/apps/admin/src/features/reports/reports-page.tsx b/apps/admin/src/features/reports/reports-page.tsx
index f917cd2..b44e412 100644
--- a/apps/admin/src/features/reports/reports-page.tsx
+++ b/apps/admin/src/features/reports/reports-page.tsx
@@ -58,7 +58,6 @@ import {
import { useNav, useToast } from "@/store/ui-store"
import type { SectionPageProps } from "@/components/shell/page-registry"
-
const LeafletMap = dynamic(() => import("@/components/map/leaflet-map").then((m) => m.LeafletMap), {
ssr: false,
loading: () => ,
@@ -146,7 +145,6 @@ function chatAuthorName(msg: ChatMessageDTO): string {
return msg.from?.name ?? "Removed"
}
-/** A sender-less status event (report status changes, etc.). Read-only; can't be deleted. */
function ChatSystemRow({ msg }: { msg: ChatMessageDTO }) {
return (
@@ -293,12 +291,8 @@ function ChatMessageRow({
)
}
-/**
- * The report chat as neighbors see it, read through the ADMIN plane, including sender-less SYSTEM status
- * events. Operators moderate here and can post into the same public thread. Remove goes through the
- * admin remove endpoint (soft-delete) and is gated to non-system rows (a status event has no author and
- * can't be removed).
- */
+// Operators moderate and post in the same public thread neighbors see. Remove is a soft-delete offered
+// only on authored rows: a system status event has no author to remove.
function ReportDiscussion({
reportId,
cityDept,
@@ -685,7 +679,7 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
const ok = await confirmDialog({
title: "Reject report",
body: sendAttempted
- ? "This rejects the report's verification verdict. It was already emailed to the city — rejecting does not recall that email."
+ ? "This rejects the report's verification verdict. It was already emailed to the city, and rejecting does not recall that email."
: "This rejects the report's verification verdict. It is not sent to the city.",
danger: true,
confirmLabel: "Reject",
@@ -699,7 +693,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
return (
Report
@@ -761,7 +752,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
- { }
Location
@@ -830,7 +820,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
- { }
{galleryMedia.length > 0 && (
@@ -878,7 +867,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
)}
- { }
Activity
@@ -915,7 +903,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
- { }
{report.linkedEvents.length > 0 && (
@@ -934,7 +921,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
)}
- { }
- { }
Reporter
@@ -976,7 +961,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
- { }
Routed to
@@ -1011,11 +995,10 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
) : (
- No contact on file — set one in Jurisdictions
+ No contact on file. Set one in Jurisdictions.
)}
- { }
@@ -1041,7 +1024,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
- { }
Send to jurisdiction
@@ -1161,7 +1143,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
- { }
Message the city
@@ -1173,7 +1154,7 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
className="rep-followup"
style={{ marginTop: 8 }}
rows={3}
- placeholder={`Message ${report.city.dept} — e.g. nudge for an update…`}
+ placeholder={`Message ${report.city.dept}, e.g. nudge for an update…`}
aria-label="Message to the city"
maxLength={FOLLOWUP_MAX}
value={text}
@@ -1198,7 +1179,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i
- { }
Quick status
{statusActions.length === 0 ? (
@@ -1293,7 +1273,7 @@ export function ReportsPage({ focusId }: SectionPageProps) {
title="Reports"
subtitle={
- Every report neighbors submit — verified, then routed to the right city department. Track
+ Every report neighbors submit, verified and then routed to the right city department. Track
status, follow up with the city, and close the loop.
}
diff --git a/apps/admin/src/features/reports/use-reports.ts b/apps/admin/src/features/reports/use-reports.ts
index 428c1b0..037249b 100644
--- a/apps/admin/src/features/reports/use-reports.ts
+++ b/apps/admin/src/features/reports/use-reports.ts
@@ -168,14 +168,11 @@ export function useSetReportVerdict() {
})
}
-/**
- * The report CHAT history on the ADMIN plane (GET /admin/reports/:id/messages), so it resolves against
- * admin.civfix.org like every other operator read. Operators see exactly what neighbors see, including
- * sender-less SYSTEM status events, and can post into the same thread. Pages run newest first; each
- * `nextCursor` asks for the window before it, so older messages stay reachable for moderation.
- */
const REPORT_CHAT_LIMIT = 50
+// Read through the admin plane so it resolves against admin.civfix.org like every other operator read.
+// Pages run newest first; each `nextCursor` asks for the window before it, so older messages stay
+// reachable for moderation.
export function useReportChatHistory(id: string | null) {
return useInfiniteQuery({
queryKey: queryKeys.reports.chat(id ?? ""),
diff --git a/apps/admin/src/features/users/use-users.ts b/apps/admin/src/features/users/use-users.ts
index 51efa73..3ef3a96 100644
--- a/apps/admin/src/features/users/use-users.ts
+++ b/apps/admin/src/features/users/use-users.ts
@@ -25,7 +25,7 @@ import { queryKeys } from "@/lib/query"
/**
* One flat page of users (home preview, the org user picker). Keyed under `users.page`, not
- * `users.list`, so its plain response can never land in — or be read as — the users page's infinite
+ * `users.list`, so its plain response can never land in, or be read as, the users page's infinite
* cache entry for the same params. `keepPreviousData` holds the last results while a new search runs.
*/
export function useUserList(params: AdminUserListQuery, opts: { keepPreviousData?: boolean } = {}) {
diff --git a/apps/admin/src/features/users/users-page.tsx b/apps/admin/src/features/users/users-page.tsx
index 7f7e939..72342c7 100644
--- a/apps/admin/src/features/users/users-page.tsx
+++ b/apps/admin/src/features/users/users-page.tsx
@@ -45,12 +45,12 @@ import {
import { getUserMessageDestination } from "./profile-activity-navigation"
import { userSearchTerm } from "./user-search"
import { isNotFound } from "@/lib/api"
+import { EMPTY_VALUE } from "@/lib/empty-value"
import { useNav, useToast, type PageId } from "@/store/ui-store"
import type { SectionPageProps } from "@/components/shell/page-registry"
type NavFn = ReturnType
-
const STATUS_VIEW: Record = {
active: { cls: "status-ok", label: USER_STATUS_LABELS.active },
suspended: { cls: "status-flag", label: USER_STATUS_LABELS.suspended },
@@ -500,7 +500,7 @@ function UserDetail({ userId }: { userId: string }) {
{user.name}
- {isMissing(user.handle) ? "—" : user.handle}
+ {isMissing(user.handle) ? EMPTY_VALUE : user.handle}
{!isMissing(user.city) && (
<>
·
@@ -523,7 +523,7 @@ function UserDetail({ userId }: { userId: string }) {
{isReportVerified && (
Report-verified
@@ -554,7 +554,7 @@ function UserDetail({ userId }: { userId: string }) {
type="button"
className="pm-item pm-copy"
onClick={onCopyId}
- title="Copy the raw account UUID (admin/DB only — not shown to neighbors)"
+ title="Copy the raw account UUID (admin/DB only, not shown to neighbors)"
>
{user.id}
@@ -607,7 +607,7 @@ function UserDetail({ userId }: { userId: string }) {
{deleted && (
- Account self-deleted — status actions disabled. Per-content removal stays available.
+ Account self-deleted, so status actions are disabled. Per-content removal stays available.
)}
@@ -684,7 +684,7 @@ function UserRow({
)}
- {isMissing(user.handle) ? "—" : user.handle}
+ {isMissing(user.handle) ? EMPTY_VALUE : user.handle}
{!isMissing(user.city) && (
@@ -763,7 +763,7 @@ export function UsersPage({ focusId }: SectionPageProps) {
title="Users"
subtitle={
- Every neighbor on civfix and what they've contributed — the reports they've
+ Every neighbor on civfix and what they've contributed: the reports they've
filed, cleanups they've joined, and messages they've sent.
}
diff --git a/apps/admin/src/hooks/use-admin-auth.ts b/apps/admin/src/hooks/use-admin-auth.ts
index de09cd5..8f01792 100644
--- a/apps/admin/src/hooks/use-admin-auth.ts
+++ b/apps/admin/src/hooks/use-admin-auth.ts
@@ -13,21 +13,10 @@ import { api, toAppError } from "@/lib/api"
import { navigateToAccessLogout } from "@/lib/access-auth"
import { useAuthStore, selectIsOperator, selectOperator } from "@/store/auth-store"
-/**
- * Operator auth hooks for the dashboard.
- *
- * Auth model (Cloudflare Access SSO, doc 16; same-origin deployment): authentication is delegated to
- * Cloudflare Access. The SPA does not collect credentials; the SPA and the `/admin/*` API sit behind the
- * same Access app on the same origin, so a credentialed same-origin request already carries the Access
- * cookie and the edge injects the JWT. The bootstrap:
- * - reuse: GET /admin/auth/session -> { authenticated, operator?, csrfToken? } (adopt if valid)
- * - exchange: POST /admin/auth/access/exchange (no body) -> { user (operator), csrfToken }
- * - logout: POST /admin/auth/logout, then GET /cdn-cgi/access/logout to end the Access session.
- *
- * The CSRF token returned by the exchange/session response is echoed on mutations via x-csrf-token.
- */
+// Cloudflare Access authenticates operators; the SPA never collects credentials. The SPA and the
+// `/admin/*` API sit behind the same Access app on the same origin, so a credentialed same-origin
+// request carries the Access cookie and the edge injects the JWT the exchange verifies.
-/** Read-side: is there an authenticated operator session right now? */
export function useOperatorSession() {
const isOperator = useAuthStore(selectIsOperator)
const operator = useAuthStore(selectOperator)
@@ -35,7 +24,6 @@ export function useOperatorSession() {
return { isOperator, operator, status }
}
-/** Map the AdminLoginResponse `user` (Phase-1 SessionResponse shape) onto the operator DTO the store holds. */
function operatorFromLogin(res: AdminLoginResponse): AdminOperatorDTO {
return {
id: res.user.id,
@@ -46,13 +34,8 @@ function operatorFromLogin(res: AdminLoginResponse): AdminOperatorDTO {
}
/**
- * Outcome of an operator bootstrap attempt:
- * - "ok": an operator session is established (reused or freshly minted); the gate unmounts.
- * - "forbidden": Access authenticated the user but the email is not on the operator allowlist (a clean
- * 403), or the session belongs to a non-operator. Terminal - show the not-authorized
- * message.
- * - "error": the exchange could not be completed (Access misconfigured / backend unreachable /
- * transient). The login screen offers a retry.
+ * "forbidden": Access authenticated the user but they are not an allowlisted operator. Terminal, since a
+ * retry would get the same answer. "error": the exchange failed for any other reason and may be retried.
*/
export type BootstrapOutcome = "ok" | "forbidden" | "error"
@@ -93,7 +76,6 @@ async function establishOperatorSession(): Promise {
useAuthStore.getState().clear("forbidden")
return "forbidden"
}
- // Anything else (Access not configured / backend down / network) is a retryable error.
useAuthStore.getState().clear()
return "error"
}
@@ -111,25 +93,17 @@ function bootstrapOperatorSession(): Promise {
}
/**
- * Establish the operator session and update the auth store. Returns a callback resolving to a
- * {@link BootstrapOutcome}. Used by the AuthHydrator on mount and by the login screen's retry button.
- * Never throws.
- *
- * It first tries to REUSE a still-valid operator session (GET /admin/auth/session) so a full-page reload
- * does not re-mint a session / write a fresh operator.login audit row. If there is none, it exchanges the
- * Access JWT (POST /admin/auth/access/exchange) for one.
+ * Never throws. A still-valid session is reused before exchanging, so a full-page reload does not mint
+ * a new session or write another operator.login audit row.
*/
export function useOperatorBootstrap(): () => Promise {
return bootstrapOperatorSession
}
/**
- * Sign the operator out: POST /admin/auth/logout (CSRF-protected), clear local state + cache, then end
- * the Cloudflare Access session by navigating to /cdn-cgi/access/logout (doc 16 sec 6.5) - otherwise the
- * next visit silently re-authenticates from the still-valid Access cookie.
- *
- * The status turns signing-out first, so the gate shows a signing-out screen instead of the dashboard
- * or the "couldn't establish your session" screen while the request and navigation run.
+ * Ends the Access session too, or the next visit silently re-authenticates from the still-valid Access
+ * cookie. The status turns signing-out first so the gate shows neither the dashboard nor the
+ * session-error screen while the request and navigation run.
*/
export function useAdminLogout(): () => Promise {
const setStatus = useAuthStore((s) => s.setStatus)
@@ -145,9 +119,8 @@ export function useAdminLogout(): () => Promise {
// logout below still ends the SSO session.
}
clear("signing-out")
- // Drop all admin data so a future operator does not see stale cache.
+ // A future operator on this tab must not see the previous one's data.
queryClient.clear()
- // End the Access session and leave the page; this navigation does not return here.
navigateToAccessLogout()
}, [setStatus, clear, queryClient])
}
diff --git a/apps/admin/src/hooks/use-admin-home.ts b/apps/admin/src/hooks/use-admin-home.ts
index e25ec28..83cac9a 100644
--- a/apps/admin/src/hooks/use-admin-home.ts
+++ b/apps/admin/src/hooks/use-admin-home.ts
@@ -6,13 +6,8 @@ import type { HomeSummaryResponse, HomeMapResponse } from "@civfix/shared"
import { api } from "@/lib/api"
import { queryKeys } from "@/lib/query"
-/**
- * Data hooks for the home/dashboard hub. Each is a plain React Query read; the home page renders the
- * loading / error / empty states from these. They are independent so a card can fail without taking
- * down the rest of the dashboard (enumeration 2.A.6: partial-failure tolerant home).
- */
+// Separate queries so one home card can fail without taking down the rest of the dashboard.
-/** GET /admin/home/summary - the per-section counts/leads the hub shows. */
export function useHomeSummary() {
return useQuery({
queryKey: queryKeys.home.summary,
@@ -20,7 +15,6 @@ export function useHomeSummary() {
})
}
-/** GET /admin/home/map - the live-map feed (recent reports + events with coords/status/flag). */
export function useHomeMap() {
return useQuery({
queryKey: queryKeys.home.map,
diff --git a/apps/admin/src/lib/access-auth.ts b/apps/admin/src/lib/access-auth.ts
index 905b03e..c704eaf 100644
--- a/apps/admin/src/lib/access-auth.ts
+++ b/apps/admin/src/lib/access-auth.ts
@@ -2,22 +2,8 @@
import { API_BASE_URL } from "@/lib/api"
-/**
- * Cloudflare Access helpers for the operator dashboard (doc 16, same-origin deployment).
- *
- * Auth model: admin authentication is delegated to Cloudflare Access (Zero Trust SSO). The SPA and the
- * `/admin/*` API are served behind the SAME Access app and the SAME origin (admin.civfix.org → Caddy
- * serves the SPA and routes `/admin/*` to the backend). So by the time the SPA loads, the browser already
- * holds the Access cookie for this origin, and a credentialed same-origin `POST /admin/auth/access/exchange`
- * carries it — Cloudflare injects the verified `Cf-Access-Jwt-Assertion` header at the edge and the
- * backend mints the operator session. No cross-origin cookie bootstrap is needed.
- */
-
-/**
- * End the Cloudflare Access session (doc 16 sec 6.5). After the app session is revoked via
- * `POST /admin/auth/logout`, navigate the browser to `/cdn-cgi/access/logout`; otherwise the next visit
- * silently re-authenticates from the still-valid Access cookie. Full-page navigation, server-safe.
- */
+// Revoking the app session alone is not enough: until Access's own logout runs, the next visit
+// silently re-authenticates from the still-valid Access cookie.
export function navigateToAccessLogout(): void {
if (typeof window === "undefined") return
window.location.assign(`${API_BASE_URL}/cdn-cgi/access/logout`)
diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts
index 5085938..2ce92a4 100644
--- a/apps/admin/src/lib/api.ts
+++ b/apps/admin/src/lib/api.ts
@@ -6,35 +6,17 @@ import { AppError, ErrorCode } from "@civfix/shared"
import { getCsrfToken, useAuthStore } from "@/store/auth-store"
/**
- * The civfix API base URL. NEXT_PUBLIC_API_URL (inlined into the static export at build time) overrides
- * it. When unset, the default is SAME-ORIGIN ("") in a production build — the operator dashboard is
- * served behind the same Cloudflare Access app and origin as the API (admin.civfix.org → Caddy serves
- * the SPA and routes /admin/* to the backend), so relative calls resolve to admin.civfix.org/admin/* and
- * carry the Access cookie. (On the raw, ungated civfix-admin.pages.dev shell those relative /admin calls
- * 404 there, keeping it inert by design.) In development the default is the local backend so `next dev`
- * can talk to a locally-running API.
+ * NEXT_PUBLIC_API_URL is inlined at build time. A production build defaults to same-origin: the SPA and
+ * the API sit behind one Cloudflare Access app on admin.civfix.org, so relative calls carry the Access
+ * cookie, and on the ungated civfix-admin.pages.dev shell they 404, which keeps that shell inert.
*/
export const API_BASE_URL: string = (
process.env.NEXT_PUBLIC_API_URL ??
(process.env.NODE_ENV === "production" ? "" : "http://localhost:8080")
).replace(/\/+$/, "")
-/**
- * Build the typed API client (admin surface).
- *
- * Auth model (web):
- * - Session is a httpOnly cookie set by the API. The shared client already sends
- * `credentials: "include"` on every request, so the cookie rides along automatically.
- * - Mutations require a CSRF token echoed in the x-csrf-token header. We read it from the auth store
- * at call time via getCsrfToken (the shared client only adds it for csrf endpoints).
- * - Every request carries `X-Client: web` so the backend can distinguish web from mobile.
- * - A 401 clears the local operator state, which flips the app to the login gate (providers.tsx
- * renders the gate whenever there is no operator session).
- *
- * `fetchImpl` is bound to window.fetch in the browser. On the server (Next build/prerender of the
- * shell) there is no window; we fall back to globalThis.fetch so module evaluation never throws. No
- * data is fetched during the static export, only the shell is emitted.
- */
+// The static export evaluates this module with no window; globalThis.fetch keeps that from throwing,
+// and nothing is fetched at build time.
function resolveFetch(): typeof fetch {
if (typeof window !== "undefined" && typeof window.fetch === "function") {
return window.fetch.bind(window)
@@ -50,6 +32,7 @@ export function getApiClient(): ApiClient {
baseURL: API_BASE_URL,
fetchImpl: resolveFetch(),
defaultHeaders: {
+ // The backend picks the cookie + CSRF transport (not bearer) from this header.
"x-client": "web",
},
getCsrfToken: () => getCsrfToken(),
@@ -58,22 +41,13 @@ export function getApiClient(): ApiClient {
return cachedClient
}
-/**
- * Singleton client for app code. Importing this is safe on the server because createApiClient does not
- * perform any I/O until a method is called.
- */
+// Safe to create at import on the server: the client performs no I/O until a method is called.
export const api: ApiClient = getApiClient()
-/**
- * Normalize any thrown value into an AppError so UI error states can rely on a consistent shape. The
- * shared client already throws AppError for HTTP failures; this also wraps network errors (e.g. the
- * backend is not running) into a friendly INTERNAL error.
- */
export function toAppError(err: unknown): AppError {
if (err instanceof AppError) return err
- // The `@civfix/shared/client` bundle carries its own copy of the AppError class, so an error thrown
- // by the API client is NOT `instanceof` the AppError exported from `@civfix/shared`. Recognize it by
- // shape and re-wrap it in the local class so `code`/`fields` checks work everywhere.
+ // `@civfix/shared/client` bundles its own AppError class, so its errors fail `instanceof` against the
+ // root export; recognize them by shape and re-wrap them so `code`/`fields` checks work everywhere.
if (isAppErrorLike(err)) {
return new AppError(err.code, err.message, {
httpStatus: err.httpStatus,
@@ -105,7 +79,6 @@ export function isAppError(err: unknown): boolean {
return err instanceof AppError || isAppErrorLike(err)
}
-/** True when the error is a not-found (used to render tidy empty states vs. hard errors). */
export function isNotFound(err: unknown): boolean {
return toAppError(err).code === ErrorCode.NOT_FOUND
}
diff --git a/apps/admin/src/lib/carto.ts b/apps/admin/src/lib/carto.ts
index 53555d0..ce171e1 100644
--- a/apps/admin/src/lib/carto.ts
+++ b/apps/admin/src/lib/carto.ts
@@ -1,15 +1,9 @@
/**
- * The publishable CARTO basemap api key (mirrors civfix-web's src/lib/carto.ts).
- *
- * Reads NEXT_PUBLIC_CARTO_API_KEY, which is INLINED into the static export at build time (changing it
- * needs a rebuild, not a restart). CARTO watermarks keyless raster tiles, so a configured key removes the
- * watermark - but the key is optional by design: when it is absent `withCartoKey` returns the tile URL
- * untouched and the Leaflet basemap renders exactly as it always has. It is a client-visible value,
- * never a secret.
+ * A publishable key, never a secret, inlined at build time. CARTO watermarks keyless raster tiles, but
+ * the key is optional: without it the basemap still renders.
*/
export const CARTO_API_KEY: string | undefined = process.env.NEXT_PUBLIC_CARTO_API_KEY
-/** Append the CARTO api key to one tile-template URL. A missing/blank key leaves the URL unchanged. */
export function withCartoKey(url: string, key: string | undefined = CARTO_API_KEY): string {
const trimmed = key?.trim()
return trimmed ? `${url}?key=${encodeURIComponent(trimmed)}` : url
diff --git a/apps/admin/src/lib/category.ts b/apps/admin/src/lib/category.ts
index 60ec973..ad7737b 100644
--- a/apps/admin/src/lib/category.ts
+++ b/apps/admin/src/lib/category.ts
@@ -8,7 +8,6 @@ import {
type ReportCategory,
} from "@civfix/shared"
-/** The canonical report categories, in the order the contract enum defines them. */
export const REPORT_CATEGORIES: readonly ReportCategory[] = ReportCategorySchema.options
export const CATEGORY_GLYPHS: Record = {
@@ -55,10 +54,9 @@ function buildCategoryReportTypeLabels(): Record {
}
/**
- * The resident-facing report types that fold into each canonical category, derived from the contract's
- * type -> category mapping plus the web picker's finer types. Lets the operator read a routing contact
- * against what a neighbor actually picked. Keyed by report type, so the web picker's resident-facing
- * label replaces the contract label for the same type instead of listing both.
+ * What residents actually pick under each category, so an operator can judge a routing contact against
+ * it. Keyed by report type, so the web picker's label replaces the contract's for the same type instead
+ * of listing both.
*/
export const CATEGORY_REPORT_TYPE_LABELS: Record =
buildCategoryReportTypeLabels()
diff --git a/apps/admin/src/lib/dates.test.ts b/apps/admin/src/lib/dates.test.ts
index bf49a78..426be01 100644
--- a/apps/admin/src/lib/dates.test.ts
+++ b/apps/admin/src/lib/dates.test.ts
@@ -1,12 +1,11 @@
import { describe, expect, it } from "vitest"
import { formatDate, formatDateTime } from "./dates"
-
-const PLACEHOLDER = "\u2014"
+import { EMPTY_VALUE } from "./empty-value"
describe("formatDate", () => {
it.each([null, undefined, ""])("renders the placeholder for %j", (value) => {
- expect(formatDate(value)).toBe(PLACEHOLDER)
+ expect(formatDate(value)).toBe(EMPTY_VALUE)
})
it.each([
@@ -24,7 +23,7 @@ describe("formatDate", () => {
describe("formatDateTime", () => {
it.each([null, undefined, ""])("renders the placeholder for %j", (value) => {
- expect(formatDateTime(value)).toBe(PLACEHOLDER)
+ expect(formatDateTime(value)).toBe(EMPTY_VALUE)
})
it.each([
diff --git a/apps/admin/src/lib/dates.ts b/apps/admin/src/lib/dates.ts
index d27f4d7..2f5835d 100644
--- a/apps/admin/src/lib/dates.ts
+++ b/apps/admin/src/lib/dates.ts
@@ -1,12 +1,14 @@
+import { EMPTY_VALUE } from "./empty-value"
+
export function formatDate(value: string | null | undefined): string {
- if (value === null || value === undefined || value === "") return "—"
+ if (value === null || value === undefined || value === "") return EMPTY_VALUE
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
}
export function formatDateTime(value: string | null | undefined): string {
- if (value === null || value === undefined || value === "") return "—"
+ if (value === null || value === undefined || value === "") return EMPTY_VALUE
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
return d.toLocaleString(undefined, {
diff --git a/apps/admin/src/lib/empty-value.ts b/apps/admin/src/lib/empty-value.ts
new file mode 100644
index 0000000..4555a77
--- /dev/null
+++ b/apps/admin/src/lib/empty-value.ts
@@ -0,0 +1,2 @@
+// The one glyph every admin surface shows for a missing value; change it here and nowhere else.
+export const EMPTY_VALUE = "-"
diff --git a/apps/admin/src/lib/error-messages.ts b/apps/admin/src/lib/error-messages.ts
index 8d99843..f5273ec 100644
--- a/apps/admin/src/lib/error-messages.ts
+++ b/apps/admin/src/lib/error-messages.ts
@@ -13,20 +13,11 @@ function isFetchFailure(err: unknown): boolean {
}
/**
- * Map a thrown value to friendly, user-actionable copy. Ported from community-web.
- *
- * Callers pass only the codes whose copy is domain-specific via `overrides`; the helper falls back to
- * the server message (when present) or a generic line. The error is normalized with toAppError first,
- * so callers can pass the raw caught value.
- *
- * - `opts.fields[field][value]` wins first: the API's validation errors carry a `fields` map (e.g.
- * `{ contactEmail: "unverified" }`), which is the only thing that tells two refusals with the same
- * error code apart.
- * - then `overrides[code]`.
- * - otherwise, when `preferServerMessage` is true (default): a request that never reached the server
- * reads as a connection problem, and an Error's own message is shown when non-empty, then
- * `fallback`. A thrown non-Error carries no message, so it gets `fallback`.
- * - set `preferServerMessage: false` to always use `fallback` for unmapped codes.
+ * Precedence: `opts.fields[field][value]` first, because the API's `fields` map (e.g.
+ * `{ contactEmail: "unverified" }`) is the only thing that tells two refusals with the same code apart;
+ * then `overrides[code]`; then, unless `preferServerMessage` is false, a connection message for a
+ * request that never reached the server or the Error's own message; then `fallback`. A thrown non-Error
+ * carries no message, so it always gets `fallback`.
*/
export function errorMessage(
err: unknown,
diff --git a/apps/admin/src/lib/event-kind.ts b/apps/admin/src/lib/event-kind.ts
index bc17be7..d065236 100644
--- a/apps/admin/src/lib/event-kind.ts
+++ b/apps/admin/src/lib/event-kind.ts
@@ -1,38 +1,23 @@
import { Icons, type IconComponent } from "@/components/icons"
import { EVENT_KIND_LABELS, type EventKind } from "@civfix/shared"
-/**
- * Canonical event-kind -> display reconciliation for the admin (mirrors the lib/report-status.ts
- * bucketing style). An event is either a Cleanup or an Other Volunteer event; this is the SINGLE source
- * the events list, the event detail, and the live map all read from for the kind label / icon / pin
- * treatment, so they never disagree. The labels themselves are the contract's EVENT_KIND_LABELS — we add
- * the admin-side visual treatment (icon + map pin fill) on top.
- *
- * Cleanup keeps the existing gold "calendar" treatment; Other Volunteer gets a distinct hands/heart glyph
- * + a moss tint so the two are distinguishable at a glance on the map and in the queue.
- */
+// The one source of an event kind's label, icon and pin for the events list, event detail and live
+// map, so they never disagree.
-/** Pill / row treatment per event kind: leading icon + label (label is the contract's EVENT_KIND_LABELS). */
export const EVENT_KIND_VIEW: Record = {
cleanup: { icon: Icons.Calendar, label: EVENT_KIND_LABELS.cleanup },
other_volunteer: { icon: Icons.Users, label: EVENT_KIND_LABELS.other_volunteer },
}
-/** The display label for an event kind (falls back to Cleanup for any unknown/new kind). */
+// An unknown kind from a newer server falls back to Cleanup rather than rendering blank.
export function eventKindLabel(kind: EventKind): string {
return EVENT_KIND_LABELS[kind] ?? EVENT_KIND_LABELS.cleanup
}
-/** The icon + label treatment for an event kind (falls back to the Cleanup treatment). */
export function eventKindView(kind: EventKind): { icon: IconComponent; label: string } {
return EVENT_KIND_VIEW[kind] ?? EVENT_KIND_VIEW.cleanup
}
-/**
- * The leaflet-map pin descriptor for an event kind. The map keys its fill/glyph off a `kind` string
- * ("event" historically); these two new kinds let the live map / minimap diverge cleanup vs other.
- * Kept here (not in leaflet-map.tsx) so the kind->pin mapping lives beside the kind->label mapping.
- */
export const EVENT_KIND_PIN_KIND: Record = {
cleanup: "event",
other_volunteer: "event-volunteer",
diff --git a/apps/admin/src/lib/event-status.ts b/apps/admin/src/lib/event-status.ts
index 4b7a895..f1253bd 100644
--- a/apps/admin/src/lib/event-status.ts
+++ b/apps/admin/src/lib/event-status.ts
@@ -1,6 +1,5 @@
import { EVENT_STATUS_LABELS, type EventStatus } from "@civfix/shared"
-/** Pill treatment per event lifecycle status, shared by the Events section and the org events tab. */
export const EVENT_STATUS_VIEW: Record = {
upcoming: { cls: "status-new", label: EVENT_STATUS_LABELS.upcoming },
in_progress: { cls: "status-progress", label: EVENT_STATUS_LABELS.in_progress },
diff --git a/apps/admin/src/lib/org-status.ts b/apps/admin/src/lib/org-status.ts
index 7a0d640..09b8fad 100644
--- a/apps/admin/src/lib/org-status.ts
+++ b/apps/admin/src/lib/org-status.ts
@@ -1,6 +1,5 @@
import type { OrgVerificationStatus } from "@civfix/shared"
-/** Pill treatment per org verification status, shared by the org list, profile and verification. */
export const ORG_STATUS_VIEW: Record = {
unverified: { label: "Unverified", cls: "priority-low" },
pending: { label: "Pending review", cls: "status-progress" },
diff --git a/apps/admin/src/lib/report-status.ts b/apps/admin/src/lib/report-status.ts
index b329f79..285bb5a 100644
--- a/apps/admin/src/lib/report-status.ts
+++ b/apps/admin/src/lib/report-status.ts
@@ -2,22 +2,14 @@ import { Icons, type IconComponent } from "@/components/icons"
import type { AdminReportStatus } from "@civfix/shared"
/**
- * Canonical report-status -> design-bucket reconciliation for the admin. This is the SINGLE source the
- * reports list, the reports detail, the users Reports tab, the home reports tile, and the live map all read
- * from, so the pill labels never disagree (they previously did — five hand-written maps that drifted).
- *
- * The civfix lifecycle is submitted -> held -> published -> acknowledged -> in_progress -> resolved
- * (+ rejected). The design surface has only three live buckets (Needs verification | In progress |
- * Completed) plus the orthogonal Removed. CRUCIAL: an authed pin is created `published` — LIVE, visible,
- * AWAITING a city contact — so published (and held, "under review") belong in the `submitted` bucket, NOT
- * Completed. Only `resolved` is Completed. This mirrors STATUS_BUCKETS in the backend
- * (admin-report-service.ts); keep the two in sync.
+ * The one status-to-bucket map every admin surface reads, so pill labels never disagree. A report is
+ * created `published`: live but still awaiting a city contact, so it (and `held`) belongs in
+ * `submitted`, and only `resolved` is Completed. Mirrors STATUS_BUCKETS in the backend's
+ * admin-report-service.ts; keep the two in sync.
*/
-/** A design status bucket: the three live buckets + the orthogonal Removed. */
export type ReportBucket = "submitted" | "in_progress" | "completed" | "removed"
-/** Map every civfix status to its design bucket. */
export const REPORT_STATUS_BUCKET: Record = {
submitted: "submitted",
held: "submitted",
@@ -28,7 +20,6 @@ export const REPORT_STATUS_BUCKET: Record = {
rejected: "removed",
}
-/** Pill treatment per design bucket: CSS pill class + leading icon + label. */
export const BUCKET_VIEW: Record<
ReportBucket,
{ cls: string; icon: IconComponent; label: string }
@@ -39,10 +30,8 @@ export const BUCKET_VIEW: Record<
removed: { cls: "status-flag", icon: Icons.Trash, label: "Removed" },
}
-/** The design bucket a civfix status falls in (for filter counts + the quick-status active state). */
export function reportBucket(status: AdminReportStatus): ReportBucket {
- // Fallback to "submitted" for any unknown/new status so a future enum value reads as new (awaiting
- // action) rather than crashing or silently dropping into Completed.
+ // A status from a newer server reads as awaiting action rather than crashing or passing as Completed.
return REPORT_STATUS_BUCKET[status] ?? "submitted"
}
@@ -58,7 +47,6 @@ export function reportNeedsAttention(status: string, flagged: boolean): boolean
return flagged || (isReportStatus(status) && reportBucket(status) === "submitted")
}
-/** The pill treatment (class + icon + label) for any civfix status, via its design bucket. */
export function reportStatusView(status: AdminReportStatus): {
cls: string
icon: IconComponent
diff --git a/apps/admin/src/store/auth-store.ts b/apps/admin/src/store/auth-store.ts
index 9e62c8d..ec7a3c7 100644
--- a/apps/admin/src/store/auth-store.ts
+++ b/apps/admin/src/store/auth-store.ts
@@ -3,26 +3,12 @@
import { create } from "zustand"
import type { AdminOperatorDTO } from "@civfix/shared"
-/**
- * Operator authentication state for the admin dashboard.
- *
- * Web uses cookie sessions: the session cookie is httpOnly and set by the API, so we never see the
- * token here. What we DO track is the CSRF token (returned by the POST /admin/auth/access/exchange
- * response and GET /admin/auth/session) which must be echoed on mutating requests via the x-csrf-token
- * header, plus the operator identity rendered across the shell.
- *
- * The API client (src/lib/api.ts) reads the current csrfToken through getCsrfToken() at call time, so
- * there is no static import cycle between the store and the client.
- */
+// The session cookie is httpOnly, so the store never holds the session token; it holds only the CSRF
+// token mutations must echo, and the operator identity.
/**
- * - idle/loading: pre-hydration or the Access exchange is in flight (show the boot/authenticating gate).
- * - authenticated: an operator session is established.
- * - anonymous: no session yet; the Access bootstrap can be (re)attempted via a top-level navigation.
- * - forbidden: Cloudflare Access authenticated the user but their email is NOT on the operator
- * allowlist (a clean 403). This is a terminal state - retrying the bootstrap would loop, so the gate
- * shows a "not authorized" message instead of redirecting.
- * - signing-out: the operator chose Sign out and the page is about to leave for the Access logout.
+ * "forbidden" is terminal: Access authenticated the user but they are not an allowlisted operator, so
+ * retrying the bootstrap would only loop.
*/
export type AuthStatus = "idle" | "loading" | "authenticated" | "anonymous" | "forbidden" | "signing-out"
@@ -30,13 +16,11 @@ export type SignedOutStatus = Extract void
setStatus: (status: AuthStatus) => void
- /** Drop the identity and CSRF token, landing in `status` (anonymous unless given). */
clear: (status?: SignedOutStatus) => void
}
@@ -48,7 +32,7 @@ export const useAuthStore = create((set) => ({
setSession: ({ operator, csrfToken }) =>
set((prev) => ({
operator,
- // Preserve an existing CSRF token if a refresh did not return a new one.
+ // A session refresh that returns no CSRF token keeps the current one.
csrfToken: csrfToken !== undefined ? csrfToken : prev.csrfToken,
status: operator ? "authenticated" : "anonymous",
})),
@@ -65,17 +49,13 @@ export const useAuthStore = create((set) => ({
})),
}))
-/**
- * Non-hook accessor for the current CSRF token. Used by the API client (which lives outside React) to
- * inject the x-csrf-token header on mutations without subscribing to the store.
- */
+// A non-hook accessor, read at call time, keeps the API client free of a static import cycle with
+// this store.
export function getCsrfToken(): string | undefined {
return useAuthStore.getState().csrfToken ?? undefined
}
-/** True only when an operator session is established and the role is actually `operator`. */
export const selectIsOperator = (s: AuthState): boolean =>
s.status === "authenticated" && s.operator?.role === "operator"
-/** Convenience selector for the operator identity. */
export const selectOperator = (s: AuthState): AdminOperatorDTO | null => s.operator
diff --git a/apps/admin/src/styles/admin.css b/apps/admin/src/styles/admin.css
index 897517f..47fa20f 100644
--- a/apps/admin/src/styles/admin.css
+++ b/apps/admin/src/styles/admin.css
@@ -1402,7 +1402,7 @@ button:focus-visible { outline: none; box-shadow: var(--ring); }
min-width: 0;
container: panel-col / inline-size;
}
-/* The panel is min(960px, 70vw), so its width — not the viewport's — decides whether two columns
+/* The panel is min(960px, 70vw), so its width, not the viewport's, decides whether two columns
fit: below ~720px of panel (a viewport under ~1030px) each column would be under 330px. */
@container panel-body (max-width: 720px) {
.panel-top { grid-template-columns: 1fr; }
@@ -2724,7 +2724,7 @@ button.prow-main { color: inherit; font: inherit; background: none; border: 0; p
========================================================= */
.mail-strip { margin-bottom: 0; }
-/* Mailbox folder switch (Outreach | Inbox) — the unified Mail screen's primary control. A segmented
+/* Mailbox folder switch (Outreach | Inbox): the unified Mail screen's primary control. A segmented
toggle, visually distinct from the contextual filter chips it sits beside in the toolbar. */
.mailbox-switch {
display: inline-flex;
@@ -3904,7 +3904,7 @@ button.prow-main { color: inherit; font: inherit; background: none; border: 0; p
/* =========================================================
Need-mapping oldest-age column + forward-email template editor (A2)
- Appended after the A1 clickability block — do not merge into it.
+ Appended after the A1 clickability block; do not merge into it.
========================================================= */
/* Oldest-waiting-report age chip in the "Need mapping" directory view. */
diff --git a/apps/admin/src/test/api-mock.ts b/apps/admin/src/test/api-mock.ts
index e8bedeb..b5365a2 100644
--- a/apps/admin/src/test/api-mock.ts
+++ b/apps/admin/src/test/api-mock.ts
@@ -6,10 +6,7 @@ type ApiMethods = { [K in keyof typeof realApi]: Mock }
const methods = new Map()
-/**
- * Stand-in for the typed API client. Every method is a lazily created `vi.fn()`; an unconfigured
- * method rejects so a test never silently renders against `undefined` data.
- */
+// An unconfigured method rejects, so a test never silently renders against `undefined` data.
export const apiMock = new Proxy({} as ApiMethods, {
get(_target, name) {
if (typeof name !== "string") return undefined
diff --git a/apps/admin/src/test/fake-timers.ts b/apps/admin/src/test/fake-timers.ts
index 86a16c5..7564ccc 100644
--- a/apps/admin/src/test/fake-timers.ts
+++ b/apps/admin/src/test/fake-timers.ts
@@ -1,11 +1,8 @@
import userEvent, { type UserEvent } from "@testing-library/user-event"
import { onTestFinished, vi } from "vitest"
-/**
- * Switches the current test to fake timers and returns a user-event instance that advances them.
- * Testing Library only detects Jest's fake timers: its async wrapper drains with a `setTimeout(0)`
- * that never fires under vitest's fakes unless a `jest` global can advance the clock.
- */
+// Testing Library only detects Jest's fake timers: its async wrapper drains with a `setTimeout(0)`
+// that never fires under vitest's fakes unless a `jest` global can advance the clock.
export function startFakeTimersWithUser(): UserEvent {
vi.useFakeTimers()
vi.stubGlobal("jest", { advanceTimersByTime: (ms: number) => vi.advanceTimersByTime(ms) })