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) { )}
- { } } - { } - { } - { } - { } - { } {d.volunteers.toLocaleString()} volunteers
- { } {d.bags > 0 ? (
{d.bags.toLocaleString()} @@ -359,7 +351,6 @@ export function AnalyticsPage(_props: SectionPageProps) { )} - { } - { } {j.resolved}%
@@ -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 }) {
- { }
Jurisdiction
@@ -467,7 +464,6 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
- { }
Notes & history
@@ -485,7 +481,6 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
- { }
Discussion @handle
@@ -496,7 +491,7 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { setHandle(e.target.value)} autoCapitalize="none" autoCorrect="off" @@ -517,7 +512,6 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
- { }
Default contact
@@ -549,7 +543,6 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
- { }
Routing contacts @@ -590,7 +583,7 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { value={contacts[c.id] ?? ""} placeholder={ attention - ? "Add a contact — reports waiting" + ? "Reports waiting: add a contact" : "e.g. publicworks@city.gov" } onChange={(e) => setCat(c.id, e.target.value)} @@ -639,7 +632,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 }) {
- { }
Meet location
@@ -512,7 +509,6 @@ function EventDetail({ eventId }: { eventId: string }) {
- { }
Activity
@@ -545,7 +541,6 @@ function EventDetail({ eventId }: { eventId: string }) {
- { } {event.eventKind === "cleanup" && (
@@ -588,7 +583,6 @@ function EventDetail({ eventId }: { eventId: string }) {
- { }
Turnout
@@ -633,7 +627,6 @@ function EventDetail({ eventId }: { eventId: string }) {
) )} - { } {(event.status === "in_progress" || event.status === "completed") && (
- { }
Organizer
@@ -688,7 +680,6 @@ function EventDetail({ eventId }: { eventId: string }) {
- { }
Message attendees
@@ -746,7 +737,6 @@ function EventDetail({ eventId }: { eventId: string }) {
- { }
Moderate
@@ -772,7 +762,6 @@ function EventDetail({ eventId }: { eventId: string }) {
)} - { } {pickerOpen && event.eventKind === "cleanup" && ( r.id))} @@ -838,7 +827,7 @@ export function EventsPage({ focusId }: SectionPageProps) { title="Events" subtitle={ - Events neighbors organize on civfix — cleanups and other volunteer events alike. Track + Cleanups and other volunteer events neighbors organize on civfix. Track turnout, keep them on the level, and message attendees. } diff --git a/apps/admin/src/features/home/home-page.tsx b/apps/admin/src/features/home/home-page.tsx index 1872ae3..efe502a 100644 --- a/apps/admin/src/features/home/home-page.tsx +++ b/apps/admin/src/features/home/home-page.tsx @@ -24,6 +24,7 @@ import { Spark } from "@/features/analytics/analytics-charts" import { LoadingState, ErrorState } from "@/components/shared/data-states" import { categoryPinSrc } from "@/lib/category" import { reportStatusView } from "@/lib/report-status" +import { EMPTY_VALUE } from "@/lib/empty-value" import { useHomeSummary } from "@/hooks/use-admin-home" import { useDiscoveryList } from "@/features/discovery/use-discovery" import { useReportList } from "@/features/reports/use-reports" @@ -39,7 +40,6 @@ import { import { PAGE_LABEL, useNav, type PageId } from "@/store/ui-store" import type { SectionPageProps } from "@/components/shell/page-registry" - const HUB_ICON: Record = { discovery: Icons.Pin, reports: Icons.FileText, @@ -101,7 +101,7 @@ function buildSummaries(d: HomeSummaryResponse | undefined): SectionSummary[] { hue: "slate", ...counted(d?.discovery.queue, "jurisdictions in queue"), blurb: - "Jurisdictions with reports waiting on routing setup — work the queue so neighbors' reports reach the right city department.", + "Jurisdictions with reports waiting on routing setup. Work the queue so neighbors' reports reach the right city department.", stats: [ { k: "Reports waiting", v: d?.discovery.reportsWaiting }, { k: "Over SLA", v: d?.discovery.overSla, tone: (d?.discovery.overSla ?? 0) > 0 ? "warn" : null }, @@ -115,7 +115,7 @@ function buildSummaries(d: HomeSummaryResponse | undefined): SectionSummary[] { hue: "lilac", ...counted(d?.reports.flagged, "reports flagged"), blurb: - "Every report neighbors submit, routed to the right city department — track status and close the loop.", + "Every report neighbors submit, routed to the right city department. Track status and close the loop.", stats: [ { k: "In progress", v: d?.reports.inProgress }, { k: "Completed", v: d?.reports.completed }, @@ -129,7 +129,7 @@ function buildSummaries(d: HomeSummaryResponse | undefined): SectionSummary[] { hue: "sun", ...counted(d?.events.upcoming, "upcoming events"), blurb: - "Community cleanups neighbors organize — track turnout, keep them legit, and message attendees.", + "Community cleanups neighbors organize. Track turnout, keep them legit, and message attendees.", stats: [ { k: "Live now", v: d?.events.live }, { k: "Attending", v: d?.events.attending }, @@ -143,7 +143,7 @@ function buildSummaries(d: HomeSummaryResponse | undefined): SectionSummary[] { hue: "sky", ...counted(d?.mail.unread, "unread messages"), blurb: - "Two-way mail with municipal contacts — outbound routing and the replies that come back.", + "Two-way mail with municipal contacts: outbound routing and the replies that come back.", stats: [ { k: "Needs action", v: d?.mail.needsAction, tone: (d?.mail.needsAction ?? 0) > 0 ? "warn" : null }, { k: "Unread", v: d?.mail.unread }, @@ -169,7 +169,7 @@ function buildSummaries(d: HomeSummaryResponse | undefined): SectionSummary[] { label: "Analytics", hue: "moss", ...counted(d?.analytics.pinsThisMonth, "pins this month"), - blurb: "The numbers are the proof civfix works — dropped, routed, resolved, cleaned up.", + blurb: "The numbers are the proof civfix works: dropped, routed, resolved, cleaned up.", stats: [{ k: "Cleanups", v: d?.analytics.cleanups }], spark: d?.analytics.pinsByWeek, metrics: d && [ @@ -192,7 +192,6 @@ function initials(name: string): string { .toUpperCase() } - const POPULATION_FORMAT = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1, @@ -281,7 +280,7 @@ function userRow(u: AdminUserListItemDTO): PeekItem { kind: "avatar", name: u.name, title: u.name, - meta: u.flagReason ?? (u.city || "—"), + meta: u.flagReason ?? (u.city || EMPTY_VALUE), age: u.lastActive, focusId: u.id, } @@ -628,7 +627,7 @@ export function HomePage(_props: SectionPageProps) { lead: presentation.lead, unit: presentation.unit, blurb: - "Two-way outreach with municipal contacts plus catch-all inbound to *@civfix.org — replies, support requests, and cold mail in one place.", + "Two-way outreach with municipal contacts plus catch-all inbound to *@civfix.org: replies, support requests, and cold mail in one place.", cta: "Open mail", stats: [ { k: "Needs action", v: needsAction, tone: needsAction > 0 ? "warn" : null }, diff --git a/apps/admin/src/features/home/home-preview-presentation.test.ts b/apps/admin/src/features/home/home-preview-presentation.test.ts index e83e244..529ebda 100644 --- a/apps/admin/src/features/home/home-preview-presentation.test.ts +++ b/apps/admin/src/features/home/home-preview-presentation.test.ts @@ -24,7 +24,7 @@ describe("home preview presentation", () => { it("leads the moderation tile with the server-side queue total when the summary carries one", () => { expect(getModerationPreviewPresentation(4)).toEqual({ lead: 4, - unit: "queued — user reports, held media, clusters and appeals", + unit: "queued · user reports, held media, clusters and appeals", }) expect(getModerationPreviewPresentation(0).lead).toBe(0) }) diff --git a/apps/admin/src/features/home/home-preview-presentation.ts b/apps/admin/src/features/home/home-preview-presentation.ts index e4e7823..499c0f7 100644 --- a/apps/admin/src/features/home/home-preview-presentation.ts +++ b/apps/admin/src/features/home/home-preview-presentation.ts @@ -5,14 +5,11 @@ export function getMailPreviewPresentation(outreachUnread: number) { } } -/** - * The moderation tile leads with the server-side queue total when the summary carries one. When the - * field is absent (an older API) it leads with a plain label instead of the length of the two-row - * preview, which is not a count of anything. - */ const MODERATION_MIX = "user reports, held media, clusters and appeals" +// Without a server-side queue total (an older API) the tile leads with a plain label, never the +// length of the two-row preview, which counts nothing. export function getModerationPreviewPresentation(queueTotal?: number) { if (queueTotal === undefined) return { lead: null, unit: MODERATION_MIX } - return { lead: queueTotal, unit: `queued — ${MODERATION_MIX}` } + return { lead: queueTotal, unit: `queued · ${MODERATION_MIX}` } } diff --git a/apps/admin/src/features/hosts/broadcast-log.tsx b/apps/admin/src/features/hosts/broadcast-log.tsx index 9bd66bf..6c53093 100644 --- a/apps/admin/src/features/hosts/broadcast-log.tsx +++ b/apps/admin/src/features/hosts/broadcast-log.tsx @@ -9,6 +9,7 @@ import type { import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { formatDateTime } from "@/lib/dates" +import { EMPTY_VALUE } from "@/lib/empty-value" import { useNav } from "@/store/ui-store" export const BROADCAST_STATUS_VIEW: Record = { @@ -95,7 +96,7 @@ export function BroadcastLog({ items }: { items: AdminBroadcastListItemDTO[] }) )}
- subject hash {item.subjectHash ?? "—"} · finished {formatDateTime(item.finishedAt)} + subject hash {item.subjectHash ?? EMPTY_VALUE} · finished {formatDateTime(item.finishedAt)}
) 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.
)} @@ -720,7 +720,6 @@ export function MailPage({ focusId }: SectionPageProps) { - { }
Outbound · 7d
-
—
+
{EMPTY_VALUE}
No outbound mail in the last 7 days
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({
Jurisdiction - {claim.jurisdictionGeoid ?? "—"} + {claim.jurisdictionGeoid ?? EMPTY_VALUE}
{claim.jurisdictionGeoid && ( diff --git a/apps/admin/src/features/moderation/moderation-page.tsx b/apps/admin/src/features/moderation/moderation-page.tsx index 2d5bceb..ddabecf 100644 --- a/apps/admin/src/features/moderation/moderation-page.tsx +++ b/apps/admin/src/features/moderation/moderation-page.tsx @@ -37,7 +37,6 @@ import { GovClaimDetail, GovClaimRow } from "@/features/moderation/gov-claims-vi import { useNav } from "@/store/ui-store" import type { SectionPageProps } from "@/components/shell/page-registry" - type ServerFilter = NonNullable type Section = "queue" | "gov_claims" @@ -241,7 +240,6 @@ function ModerationDetail({ itemId, onResolved }: { itemId: string; onResolved: return (
- { }
{React.createElement(KIND_ICON[item.kind] ?? Icons.Shield, { size: 20 })} @@ -272,7 +270,6 @@ function ModerationDetail({ itemId, onResolved }: { itemId: string; onResolved:
- { }
Report
@@ -310,7 +307,6 @@ function ModerationDetail({ itemId, onResolved }: { itemId: string; onResolved:
- { } {item.media.length > 0 && (
Media
@@ -362,7 +358,6 @@ function ModerationDetail({ itemId, onResolved }: { itemId: string; onResolved:
)} - { } {item.signals.length > 0 && (
Signals
@@ -376,7 +371,6 @@ function ModerationDetail({ itemId, onResolved }: { itemId: string; onResolved:
)} - { } {item.similar.length > 0 && (
Similar items
@@ -411,7 +405,6 @@ function ModerationDetail({ itemId, onResolved }: { itemId: string; onResolved:
- { }
User context
@@ -482,7 +475,6 @@ function ModerationDetail({ itemId, onResolved }: { itemId: string; onResolved:
- { }
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 })
Kind - {org.verifiedKind ? ORG_KIND_LABEL[org.verifiedKind] : "—"} + {org.verifiedKind ? ORG_KIND_LABEL[org.verifiedKind] : EMPTY_VALUE}
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 }) {
Kind - {org.verifiedKind ? ORG_KIND_LABEL[org.verifiedKind] : "—"} + {org.verifiedKind ? ORG_KIND_LABEL[org.verifiedKind] : EMPTY_VALUE}
Website @@ -102,7 +103,7 @@ export function VerificationPanel({ orgId }: { orgId: string }) { ) : org.websiteUrl ? ( org.websiteUrl ) : ( - "—" + EMPTY_VALUE )}
@@ -156,7 +157,7 @@ export function VerificationPanel({ orgId }: { orgId: string }) {
Requested kind - {verification.kind ? ORG_KIND_LABEL[verification.kind] : "—"} + {verification.kind ? ORG_KIND_LABEL[verification.kind] : EMPTY_VALUE}
Submitted @@ -164,7 +165,7 @@ export function VerificationPanel({ orgId }: { orgId: string }) {
Submitted by - {verification.submittedBy?.name ?? "—"} + {verification.submittedBy?.name ?? EMPTY_VALUE}
Reviewed @@ -172,7 +173,7 @@ export function VerificationPanel({ orgId }: { orgId: string }) {
Reviewed by - {verification.reviewedBy?.name ?? "—"} + {verification.reviewedBy?.name ?? EMPTY_VALUE}
{verification.note &&

{verification.note}

} diff --git a/apps/admin/src/features/pages/page-blocks.test.ts b/apps/admin/src/features/pages/page-blocks.test.ts index db1ba8d..98743c6 100644 --- a/apps/admin/src/features/pages/page-blocks.test.ts +++ b/apps/admin/src/features/pages/page-blocks.test.ts @@ -26,7 +26,7 @@ describe("signup page block extraction", () => { { time: null, title: "Cleanup" }, ], }) - expect(view.lines).toEqual(["9:00 — Check-in", "Grab a bag", "Cleanup"]) + expect(view.lines).toEqual(["9:00 · Check-in", "Grab a bag", "Cleanup"]) }) it("collects outbound links without duplicating them", () => { diff --git a/apps/admin/src/features/pages/page-blocks.ts b/apps/admin/src/features/pages/page-blocks.ts index 8dbf664..3f0a3ca 100644 --- a/apps/admin/src/features/pages/page-blocks.ts +++ b/apps/admin/src/features/pages/page-blocks.ts @@ -60,7 +60,7 @@ export function pageBlockView(block: EventPageBlock): PageBlockView { for (const item of block.items) { const when = plain(item.time) const what = plain(item.title) - lines.push(when === "" ? what : `${when} — ${what}`) + lines.push(when === "" ? what : `${when} · ${what}`) pushText(lines, item.description) } break @@ -68,7 +68,7 @@ export function pageBlockView(block: EventPageBlock): PageBlockView { title = plain(block.title) || null for (const entry of block.entries) { const role = plain(entry.role) - lines.push(role === "" ? plain(entry.name) : `${plain(entry.name)} — ${role}`) + lines.push(role === "" ? plain(entry.name) : `${plain(entry.name)} · ${role}`) pushText(lines, entry.bio) } break diff --git a/apps/admin/src/features/pages/pages-page.tsx b/apps/admin/src/features/pages/pages-page.tsx index 9c69649..3db0586 100644 --- a/apps/admin/src/features/pages/pages-page.tsx +++ b/apps/admin/src/features/pages/pages-page.tsx @@ -14,6 +14,7 @@ import { promptDialog } from "@/components/shared/dialog" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" import { useDebounced } from "@/hooks/use-debounced" import { formatDateTime } from "@/lib/dates" +import { EMPTY_VALUE } from "@/lib/empty-value" import { pageListParams, pageRowFromDTO } from "@/features/pages/pages-filters" import { publicPagePath } from "@/features/pages/page-path" import { PagePreview } from "@/features/pages/page-preview" @@ -184,11 +185,11 @@ function PageDetail({ item }: { item: AdminEventPageListItemDTO }) {
Organization - {item.orgName ?? "—"} + {item.orgName ?? EMPTY_VALUE}
Organizer - {item.organizer?.name ?? "—"} + {item.organizer?.name ?? EMPTY_VALUE}
{flagged && (
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 (
- { }
{pin ? ( @@ -723,7 +716,6 @@ function ReportDetail({ reportId, onRemoved }: { reportId: string; onRemoved: (i Flagged )} - { }
- { }
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) })