From 7165fd3d778236101d8548d6f013ec955d5e702e Mon Sep 17 00:00:00 2001 From: Theo Date: Thu, 24 Sep 2026 08:16:31 +0000 Subject: [PATCH 1/5] mail and discovery: split the page and detail god components, name magic values, typed mailbox state --- .../discovery/directory-query.test.ts | 70 + .../src/features/discovery/discovery-page.tsx | 1137 ++++------------- .../features/discovery/discovery-ui-state.ts | 61 +- .../discovery/jurisdiction-detail-panels.tsx | 353 +++++ .../discovery/jurisdiction-detail.tsx | 120 ++ .../features/discovery/jurisdiction-row.tsx | 147 +++ .../discovery/jurisdiction-view.test.ts | 65 + .../features/discovery/jurisdiction-view.ts | 108 ++ .../discovery/routing-status-pill.tsx | 53 + .../features/discovery/unmapped-detail.tsx | 72 ++ .../src/features/discovery/use-discovery.ts | 11 +- .../discovery/use-jurisdiction-editor.ts | 150 +++ apps/admin/src/features/inbox/attachments.ts | 19 +- .../src/features/inbox/inbox-feed.test.ts | 2 + apps/admin/src/features/inbox/inbox-views.tsx | 40 +- .../admin/src/features/mail/compose-modal.tsx | 134 ++ .../mail/forward-template-insert.test.ts | 23 + .../mail/forward-template-modal-state.ts | 10 + .../features/mail/forward-template-modal.tsx | 388 ++++-- .../src/features/mail/mail-detail-pane.tsx | 51 + .../src/features/mail/mail-list-pane.tsx | 138 ++ .../src/features/mail/mail-page-state.test.ts | 94 ++ .../src/features/mail/mail-page-state.ts | 74 ++ apps/admin/src/features/mail/mail-page.tsx | 1003 +++------------ apps/admin/src/features/mail/mail-reader.tsx | 332 +++++ apps/admin/src/features/mail/mail-row.tsx | 54 + .../src/features/mail/mail-stats-strip.tsx | 82 ++ .../src/features/mail/mailbox-switch.tsx | 71 + apps/admin/src/features/mail/use-mailbox.ts | 217 ++++ 29 files changed, 3152 insertions(+), 1927 deletions(-) create mode 100644 apps/admin/src/features/discovery/directory-query.test.ts create mode 100644 apps/admin/src/features/discovery/jurisdiction-detail-panels.tsx create mode 100644 apps/admin/src/features/discovery/jurisdiction-detail.tsx create mode 100644 apps/admin/src/features/discovery/jurisdiction-row.tsx create mode 100644 apps/admin/src/features/discovery/jurisdiction-view.test.ts create mode 100644 apps/admin/src/features/discovery/jurisdiction-view.ts create mode 100644 apps/admin/src/features/discovery/routing-status-pill.tsx create mode 100644 apps/admin/src/features/discovery/unmapped-detail.tsx create mode 100644 apps/admin/src/features/discovery/use-jurisdiction-editor.ts create mode 100644 apps/admin/src/features/mail/compose-modal.tsx create mode 100644 apps/admin/src/features/mail/forward-template-insert.test.ts create mode 100644 apps/admin/src/features/mail/mail-detail-pane.tsx create mode 100644 apps/admin/src/features/mail/mail-list-pane.tsx create mode 100644 apps/admin/src/features/mail/mail-page-state.test.ts create mode 100644 apps/admin/src/features/mail/mail-page-state.ts create mode 100644 apps/admin/src/features/mail/mail-reader.tsx create mode 100644 apps/admin/src/features/mail/mail-row.tsx create mode 100644 apps/admin/src/features/mail/mail-stats-strip.tsx create mode 100644 apps/admin/src/features/mail/mailbox-switch.tsx create mode 100644 apps/admin/src/features/mail/use-mailbox.ts diff --git a/apps/admin/src/features/discovery/directory-query.test.ts b/apps/admin/src/features/discovery/directory-query.test.ts new file mode 100644 index 0000000..a619460 --- /dev/null +++ b/apps/admin/src/features/discovery/directory-query.test.ts @@ -0,0 +1,70 @@ +import { hashKey } from "@tanstack/react-query" +import { describe, expect, it } from "vitest" + +import { EMPTY_VALUE } from "@/lib/empty-value" + +import { directoryCounts, toDirectoryQuery } from "./discovery-ui-state" + +describe("directory query", () => { + it.each([ + ["attention", "pop", { filter: "needs_mapping", sort: "oldest" }], + ["clear", "pop", { filter: "routed", sort: "population" }], + ["all", "reports", { filter: "all", sort: "reports" }], + ["all", "oldest", { filter: "all", sort: "oldest" }], + ] as const)("maps %s sorted by %s", (filter, sort, expected) => { + expect(toDirectoryQuery(filter, sort, "all", "")).toEqual(expected) + }) + + it("adds the layer and search only when set", () => { + expect(toDirectoryQuery("all", "pop", "county", "06")).toEqual({ + filter: "all", + sort: "population", + layer: "county", + q: "06", + }) + expect(hashKey([toDirectoryQuery("all", "pop", "all", "")])).toBe( + hashKey([{ filter: "all", sort: "population" }]), + ) + }) +}) + +describe("directory counts", () => { + const list = { isLoading: false, isError: false } + + it("splits the facets across the chips and heads the list with the active filter's count", () => { + const counts = directoryCounts({ + filter: "clear", + total: 12, + facets: { routed: 5, unrouted: 7 }, + needsMapping: 3, + list, + loadedCount: 2, + }) + expect(counts).toEqual({ chips: { attention: 3, clear: 5, all: 12 }, header: 5 }) + }) + + it("heads Needs mapping with its own count query", () => { + const counts = directoryCounts({ + filter: "attention", + total: 40, + facets: null, + needsMapping: "Loading…", + list, + loadedCount: 0, + }) + expect(counts.header).toBe("Loading…") + expect(counts.chips.all).toBe(EMPTY_VALUE) + }) + + it("falls back to the loaded rows when the server sent no total", () => { + const counts = directoryCounts({ + filter: "all", + total: null, + facets: null, + needsMapping: 0, + list, + loadedCount: 4, + }) + expect(counts.header).toBe(4) + }) +}) diff --git a/apps/admin/src/features/discovery/discovery-page.tsx b/apps/admin/src/features/discovery/discovery-page.tsx index 093a075..011b3d6 100644 --- a/apps/admin/src/features/discovery/discovery-page.tsx +++ b/apps/admin/src/features/discovery/discovery-page.tsx @@ -1,864 +1,285 @@ "use client" import * as React from "react" -import dynamic from "next/dynamic" -import { - DEFAULT_FORWARD_BODY_TEMPLATE, - DEFAULT_FORWARD_SUBJECT_TEMPLATE, - type DiscoveryContact, - type JurisdictionDirectoryDTO, - type JurisdictionLayer, - type PerCategoryCounts, - type ReportCategory, -} from "@civfix/shared" +import type { JurisdictionDirectoryDTO } from "@civfix/shared" import { Icons } from "@/components/icons" -import { errorMessage } from "@/lib/error-messages" -import { EMPTY_VALUE } from "@/lib/empty-value" -import { - REPORT_CATEGORIES, - categoryLabel, - categoryPinSrc, - categoryReportTypes, -} from "@/lib/category" -import { promptDialog } from "@/components/shared/dialog" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" -import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" -import { - useJurisdictionDirectory, - useJurisdictionGeometry, - usePatchJurisdiction, - useSaveJurisdictionContacts, -} from "@/features/discovery/use-discovery" +import { useJurisdictionDirectory } from "@/features/discovery/use-discovery" import { + directoryCounts, getCountDisplay, getJurisdictionSort, initialDirectoryState, pickSelected, + toDirectoryQuery, + type CountDisplay, type JurisdictionFilter, type JurisdictionSort, } from "@/features/discovery/discovery-ui-state" +import { JurisdictionDetail } from "@/features/discovery/jurisdiction-detail" +import { JurisdictionRow } from "@/features/discovery/jurisdiction-row" import { - afterContactsSaved, - contactsPayload, - jurisdictionFields, - noteAndHandleFields, - parseHandle, - routingContactsPayload, - withContactEdit, - type ContactEdits, -} from "@/features/discovery/discovery-payloads" -import { ForwardTemplateModal } from "@/features/mail/forward-template-modal" -import { useForwardTemplateDefault } from "@/features/mail/use-mail" - -const BoundaryMap = dynamic( - () => import("@/components/map/boundary-map").then((m) => m.BoundaryMap), - { ssr: false }, -) -import { useToast } from "@/store/ui-store" + LAYER_OPTIONS, + UNMAPPED_GEOID, + type LayerChoice, +} from "@/features/discovery/jurisdiction-view" +import { UnmappedDetail } from "@/features/discovery/unmapped-detail" +import { SEARCH_DEBOUNCE_MS } from "@/lib/timing" import type { SectionPageProps } from "@/components/shell/page-registry" -const LAYER_LABEL: Record = { - place: "City", - county: "County", - state: "State", - federal: "Federal land", - tribal: "Tribal", -} - -const UNMAPPED_GEOID = "__unmapped__" +type DirectoryList = ReturnType -function fmtRouted(iso: string | null): string { - if (!iso) return EMPTY_VALUE - const d = new Date(iso) - if (Number.isNaN(d.getTime())) return EMPTY_VALUE - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) -} - -const HOUR_MS = 60 * 60 * 1000 +function useDirectoryFilters(focusId: string | null) { + const [initial] = React.useState(() => initialDirectoryState(focusId)) + const [filter, setFilter] = React.useState(initial.filter) + const [layer, setLayer] = React.useState("all") + const [sort, setSort] = React.useState("pop") + const [query, setQuery] = React.useState(initial.query) + const [debouncedQuery, setDebouncedQuery] = React.useState(initial.query) -function fmtAge(iso: string | null): string { - if (!iso) return EMPTY_VALUE - const then = new Date(iso).getTime() - 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)) - if (mins < 60) return `${mins}m` - const hours = Math.floor(diff / HOUR_MS) - if (hours < 24) return `${hours}h` - const days = Math.floor(diff / (24 * HOUR_MS)) - if (days < 14) return `${days}d` - const weeks = Math.floor(days / 7) - if (weeks < 8) return `${weeks}w` - const months = Math.floor(days / 30) - if (months < 12) return `${months}mo` - return `${Math.floor(days / 365)}y` -} + React.useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [query]) + React.useEffect(() => { + if (!focusId) return + const next = initialDirectoryState(focusId) + setFilter(next.filter) + setQuery(next.query) + setDebouncedQuery(next.query) + }, [focusId]) -function isOverdue(iso: string | null): boolean { - if (!iso) return false - const then = new Date(iso).getTime() - if (Number.isNaN(then)) return false - return Date.now() - then > 24 * HOUR_MS + return { filter, setFilter, layer, setLayer, sort, setSort, query, setQuery, debouncedQuery } } -const REPORT_TYPES: { id: ReportCategory; label: string; pin: string; types: string }[] = - REPORT_CATEGORIES.map((id) => ({ - id, - label: categoryLabel(id), - pin: categoryPinSrc(id), - types: categoryReportTypes(id), - })) +// Only the first load picks a jurisdiction on the operator's behalf, and a deep-linked one is never +// replaced. A pick that a filter or search leaves out stays open as it was last listed; a pick that +// drops out of the same list after a refetch (a Save & route under Needs mapping) clears, so the pane +// never shows a stale row or jumps to another jurisdiction's save buttons. Decided only on data +// fetched for the current params. +function useJurisdictionSelection({ + focusId, + items, + listQuery, + listKey, +}: { + focusId: string | null + items: JurisdictionDirectoryDTO[] + listQuery: DirectoryList + listKey: string +}) { + const [selectedId, setSelectedId] = React.useState(focusId) + const [lastSeen, setLastSeen] = React.useState(null) + React.useEffect(() => { + if (focusId) setSelectedId(focusId) + }, [focusId]) -function routingCount(counts: PerCategoryCounts, id: ReportCategory): number { - return counts[id] ?? 0 -} + const selected = pickSelected(items, selectedId, lastSeen) + React.useEffect(() => { + if (selected && selected !== lastSeen) setLastSeen(selected) + }, [selected, lastSeen]) -function dominantCategory(counts: PerCategoryCounts): ReportCategory | null { - let best: ReportCategory | null = null - let bestN = 0 - for (const c of REPORT_CATEGORIES) { - const n = counts[c] ?? 0 - if (n > bestN) { - bestN = n - best = c + const [autoPick, setAutoPick] = React.useState(focusId === null) + const seenIn = React.useRef<{ id: string; list: string } | null>(null) + React.useEffect(() => { + if (!listQuery.isSuccess || listQuery.isFetching) return + if (selectedId === null) { + if (autoPick && items[0]) setSelectedId(items[0].geoid) + return } - } - return best -} - -function needsAttention(dto: JurisdictionDirectoryDTO): boolean { - return dto.reportsWaiting > 0 && dto.method === "none" -} + setAutoPick(false) + if (items.some((x) => x.geoid === selectedId)) seenIn.current = { id: selectedId, list: listKey } + else if (seenIn.current?.id === selectedId && seenIn.current.list === listKey) setSelectedId(null) + }, [listQuery.isSuccess, listQuery.isFetching, items, selectedId, listKey, autoPick]) -function fmtPop(n: number): string { - if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k" - return String(n) + const notListed = + selectedId !== null && selected === null && !listQuery.isLoading && !listQuery.isError + return { selectedId, setSelectedId, selected, notListed } } -function JurisdictionRow({ - item, - selected, - onClick, - showOldest = false, +function DirectoryToolbar({ + filters, + chips, }: { - item: JurisdictionDirectoryDTO - selected: boolean - onClick: () => void - showOldest?: boolean + filters: ReturnType + chips: Record }) { - const isUnmapped = item.geoid === UNMAPPED_GEOID - const overdue = showOldest && isOverdue(item.oldestReportAt) - const needs = needsAttention(item) - const dom = dominantCategory(item.perCategoryCounts) - const pin = dom ? categoryPinSrc(dom) : null - const onKeyDown = (event: React.KeyboardEvent) => { - if (!isKeyboardActivationKey(event.key)) return - event.preventDefault() - onClick() - } - - if (isUnmapped) { - return ( -
-
- -
-
-
- {item.org} - - - Needs mapping - -
-
- {item.reportsWaiting} waiting - · - location didn’t resolve to a jurisdiction -
-
-
- {showOldest && item.oldestReportAt && ( - - {overdue && } - {fmtAge(item.oldestReportAt)} - - )} - - - -
-
- ) - } - + const { filter, setFilter, layer, setLayer, sort, setSort, query, setQuery } = filters return ( -
-
- {pin ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( - - )} +
+ setFilter(v as JurisdictionFilter)} + /> +
+
+ + setQuery(e.target.value)} + />
-
-
- {item.org} - {LAYER_LABEL[item.layer]} - {item.status === "bounced" ? ( - - Bounced - - ) : needs ? ( - - - Needs contact - - ) : ( - - Routed - - )} - {item.flaggedAt && ( - - Flagged - - )} - {item.geoid} -
-
- {fmtPop(item.population)} pop - · - {item.reportsWaiting} waiting - · - {item.coverage} -
+
+ Type +
-
- {showOldest ? ( - - {overdue && } - {fmtAge(item.oldestReportAt)} - - ) : ( - {fmtRouted(item.lastRouted)} - )} - - - +
+ Sort +
) } -function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) { - const toast = useToast() - const patch = usePatchJurisdiction() - const saveContacts = useSaveJurisdictionContacts() - const geometry = useJurisdictionGeometry(dto.geoid) - - const [contacts, setContacts] = React.useState>(() => { - const seed: Record = {} - dto.contacts.forEach((c: DiscoveryContact) => { - if (c.email) seed[c.category] = c.email - }) - return seed - }) - const [contactEdits, setContactEdits] = React.useState({}) - const [opNote, setOpNote] = React.useState("") - const [defaultEmail, setDefaultEmail] = React.useState(dto.email ?? "") - const [formUrl, setFormUrl] = React.useState(dto.form ?? "") - const [handle, setHandle] = React.useState(dto.handle ?? "") - - const defaultTemplate = useForwardTemplateDefault() - const [templateOpen, setTemplateOpen] = React.useState(false) - const hasCustomTemplate = - dto.forwardSubjectTemplate !== null || dto.forwardBodyTemplate !== null - const storedDefault = defaultTemplate.data - const templateFallback = - storedDefault && (storedDefault.subjectTemplate !== null || storedDefault.bodyTemplate !== null) - ? { subject: storedDefault.subjectTemplate ?? "", body: storedDefault.bodyTemplate ?? "" } - : { subject: DEFAULT_FORWARD_SUBJECT_TEMPLATE, body: DEFAULT_FORWARD_BODY_TEMPLATE } - - const counts = dto.perCategoryCounts - const setCat = (id: string, email: string) => { - setContacts((prev) => ({ ...prev, [id]: email })) - setContactEdits((prev) => withContactEdit(prev, id)) - } - const contactsSaved = (sent: ContactEdits) => - setContactEdits((prev) => afterContactsSaved(prev, sent)) - const hasDefault = defaultEmail.trim() !== "" - const missingContacts = REPORT_TYPES.filter( - (c) => routingCount(counts, c.id) > 0 && !contacts[c.id] && !hasDefault, - ).length - const filledCount = REPORT_TYPES.filter((c) => contacts[c.id]).length - const canSave = filledCount > 0 || hasDefault - const needs = needsAttention(dto) - const dom = dominantCategory(counts) - const headPin = dom ? categoryPinSrc(dom) : null - const isFlagged = dto.flaggedAt !== null - const busy = saveContacts.isPending || patch.isPending - const parsedHandle = parseHandle(handle) - const saveBlocked = busy || parsedHandle.error !== null - - const onFlag = async () => { - if (!isFlagged) { - const reason = await promptDialog({ title: "Flag jurisdiction", label: "Reason (optional)" }) - if (reason === null) return - patch.mutate({ - request: { geoid: dto.geoid, flagged: true, flagReason: reason || undefined }, - org: dto.org, - action: "flag", - }) - return - } - patch.mutate({ request: { geoid: dto.geoid, flagged: false }, org: dto.org, action: "flag" }) +function DirectoryListBody({ + listQuery, + items, + selectedGeoid, + onSelect, + showOldest, +}: { + listQuery: DirectoryList + items: JurisdictionDirectoryDTO[] + selectedGeoid: string | undefined + onSelect: (geoid: string) => void + showOldest: boolean +}) { + if (listQuery.isLoading) return + if (listQuery.isError) { + return listQuery.refetch()} /> } - - const onSaveDraft = () => { - const sentEdits = contactEdits - const contactFields = contactsPayload(sentEdits, contacts) - const jf = jurisdictionFields(defaultEmail, formUrl) - const extras = noteAndHandleFields(opNote, parsedHandle.value, dto.handle) - if ( - Object.keys(contactFields).length === 0 && - Object.keys(jf).length === 0 && - Object.keys(extras).length === 0 - ) { - toast("Nothing to save yet") - return - } - patch.mutate( - { - request: { geoid: dto.geoid, contacts: contactFields, ...jf, ...extras }, - org: dto.org, - action: "draft", - }, - { onSuccess: () => contactsSaved(sentEdits) }, + if (items.length === 0) { + return ( + } + /> ) } - - const onSaveContacts = async () => { - if (!canSave) return - const extras = noteAndHandleFields(opNote, parsedHandle.value, dto.handle) - const savesExtrasFirst = Object.keys(extras).length > 0 - const sentEdits = contactEdits - const saveAndRoute = () => - saveContacts.mutate( - { - request: { - geoid: dto.geoid, - contacts: routingContactsPayload(sentEdits, contacts), - ...jurisdictionFields(defaultEmail, formUrl), - }, - org: dto.org, - ...(savesExtrasFirst ? { savedFirst: extras } : {}), - }, - { onSuccess: () => contactsSaved(sentEdits) }, - ) - if (!savesExtrasFirst) { - saveAndRoute() - return - } - // Awaited rather than chained through a per-call callback: that is dropped once this pane unmounts, - // which would save the note but never the contacts. - try { - await patch.mutateAsync({ request: { geoid: dto.geoid, ...extras }, org: dto.org, action: "extras" }) - } catch { - // The query client already toasted the failure, and the contacts must not save without the note. - return - } - saveAndRoute() - } - return ( -
-
- - {headPin ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( - - )} - -
-
- {LAYER_LABEL[dto.layer]} · Jurisdiction · GEOID {dto.geoid} -
-

{dto.org}

-
- {dto.status === "bounced" ? ( - - Bounced - - ) : needs ? ( - - - Needs contact - - ) : ( - - Routed - - )} -
- -
-
-
-
Jurisdiction
-
-
- {geometry.data ? ( - - ) : ( -
- {geometry.isLoading ? "Loading boundary…" : dto.org} -
- )} -
-
-
-
Population
-
{dto.population.toLocaleString()}
-
- {dto.population > 0 ? "US Census ACS" : "Not available"} -
-
-
-
Reports waiting
-
{dto.reportsWaiting}
-
{LAYER_LABEL[dto.layer]}
-
-
-
-
- -
-
Notes & history
-
-
-