Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
04f2a21
Shared event + user status labels, drop phantom activity cache keys
theobong Sep 22, 2026
fb1241a
Contract-derived categories, report-type captions, Jurisdictions tabs…
theobong Sep 22, 2026
6c27165
One-step verify and send to city, city-only report follow-up, Reports…
theobong Sep 22, 2026
11628cd
Honest mail chips from stats, drop duplicate counting queries and fak…
theobong Sep 22, 2026
eff1c9c
Gov claims queue in Moderation: list, detail, verify, approve, reject
theobong Sep 22, 2026
4d1feb6
Shared muted caption class for category types and gov check evidence
theobong Sep 22, 2026
dd31eb2
Sync category pin assets to the token palette, live map reads shared …
theobong Sep 22, 2026
c42ce4d
Drop stray local launch config from the branch
theobong Sep 22, 2026
ccdd2d5
Adopt @civfix/shared 0.53.0
theobong Sep 22, 2026
d68b67f
Live map reads the report status buckets, one label per report type, …
theobong Sep 22, 2026
8cdd3fb
Home tiles read the moderation queue and inbox unread totals
theobong Sep 22, 2026
fcd3ada
Report chat on the admin plane with a composer, standalone approve, n…
theobong Sep 22, 2026
03f0faa
Moderation and users chips use the server facets and counts
theobong Sep 22, 2026
607c7eb
Gov claims: honest approve confirm, blocked approve without a contact…
theobong Sep 22, 2026
eff43c3
Save and route persists the operator note and the @handle, honest copy
theobong Sep 22, 2026
76868bb
Approve stays reachable when verify-and-send cannot approve, honest c…
theobong Sep 22, 2026
8e26ddf
Keep the local Claude launch config out of the repo
theobong Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ Thumbs.db

# Test / coverage
coverage/

# Local Claude Code scratch (launch configs, session settings)
.claude/
2 changes: 1 addition & 1 deletion apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"clean": "rimraf .next out *.tsbuildinfo"
},
"dependencies": {
"@civfix/shared": "^0.52.0",
"@civfix/shared": "^0.53.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Use the changeset workflow

This directly changes the @civfix/shared version range and refreshes the lockfile without adding a changeset. The repository directive requires dependency version changes to use the changeset workflow. This repository requirement must be satisfied before merging.

Rule Used: # civfix review rules civfix is a live civic-tech platform that will hold government contracts. Review every PR for correctness, security and performance. Flag real defects with evidence; skip style nits that lint already covers. ## Repos - **... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/admin/package.json
Line: 18

Comment:
**Use the changeset workflow**

This directly changes the `@civfix/shared` version range and refreshes the lockfile without adding a changeset. The repository directive requires dependency version changes to use the changeset workflow. This repository requirement must be satisfied before merging.

**Rule Used:** # civfix review rules  civfix is a live civic-tech platform that will hold government contracts. Review every PR for **correctness, security and performance**. Flag real defects with evidence; skip style nits that lint already covers.  ## Repos  - **... ([source](https://app.greptile.com/civfix/-/custom-context?memory=39a53925-3d93-4c82-980e-27b67393717d))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

"@tanstack/react-query": "^5.62.7",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/public/ds/pin-cleanup.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/admin/public/ds/pin-encampment.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/admin/public/ds/pin-graffiti.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/admin/public/ds/pin-hazard.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/admin/public/ds/pin-recycling.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/admin/public/ds/pin-trash.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/admin/public/ds/pin-water.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 17 additions & 8 deletions apps/admin/src/components/map/live-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import { Icons } from "@/components/icons"
import { useHomeMap } from "@/hooks/use-admin-home"
import { useNav } from "@/store/ui-store"
import { EVENT_KIND_PIN_KIND } from "@/lib/event-kind"
import {
BUCKET_VIEW,
reportBucketOf,
reportNeedsAttention,
type ReportBucket,
} from "@/lib/report-status"
import type { MapPin, MapTint } from "@/components/map/leaflet-map"


Expand All @@ -16,8 +22,6 @@ const LeafletMap = dynamic(() => import("@/components/map/leaflet-map").then((m)
loading: () => <div className="pi-map-canvas" aria-busy="true" />,
})

const WAITING_REPORT_STATUSES = new Set(["submitted", "held", "published"])

function toMapPin(p: HomeMapPin): MapPin & {
refType: HomeMapPin["refType"]
refId: string
Expand All @@ -26,7 +30,7 @@ function toMapPin(p: HomeMapPin): MapPin & {
title: string
} {
const isEvent = p.refType === "event"
const needs = !isEvent && (p.flagged || WAITING_REPORT_STATUSES.has(p.status))
const needs = !isEvent && reportNeedsAttention(p.status, p.flagged)
return {
id: `${p.refType}-${p.id}`,
refType: p.refType,
Expand All @@ -46,13 +50,18 @@ function toMapPin(p: HomeMapPin): MapPin & {

type ActivePin = ReturnType<typeof toMapPin>

const BUCKET_TONE: Record<ReportBucket, string> = {
submitted: "var(--ink-2)",
in_progress: "var(--lilac-600)",
completed: "var(--moss-700)",
removed: "var(--bloom-700)",
}

function statusTone(m: ActivePin): { color: string; label: string } {
if (m.refType === "event") return { color: "var(--sun-700)", label: "Cleanup event" }
if (m.flagged) return { color: "var(--bloom-700)", label: "Flagged" }
if (WAITING_REPORT_STATUSES.has(m.status)) return { color: "var(--ink-2)", label: "Submitted" }
if (m.status === "in_progress" || m.status === "acknowledged")
return { color: "var(--lilac-600)", label: "In progress" }
return { color: "var(--moss-700)", label: "Completed" }
const bucket = reportBucketOf(m.status)
return { color: BUCKET_TONE[bucket], label: BUCKET_VIEW[bucket].label }
}

export function LiveMap({ tint = "voyager" }: { tint?: MapTint }) {
Expand All @@ -65,7 +74,7 @@ export function LiveMap({ tint = "voyager" }: { tint?: MapTint }) {
const reportCount = pins.filter((p) => p.refType === "report").length
const eventCount = pins.filter((p) => p.refType === "event").length
const needsAttention = pins.filter(
(p) => p.refType === "report" && (p.flagged || WAITING_REPORT_STATUSES.has(p.status)),
(p) => p.refType === "report" && reportNeedsAttention(p.status, p.flagged),
).length

const openActive = () => {
Expand Down
121 changes: 79 additions & 42 deletions apps/admin/src/features/discovery/discovery-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {

import { Icons } from "@/components/icons"
import { toAppError } from "@/lib/api"
import { categoryLabel, categoryPinSrc } from "@/lib/category"
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"
Expand Down Expand Up @@ -89,19 +94,13 @@ function isOverdue(iso: string | null): boolean {
return Date.now() - then > 24 * HOUR_MS
}

const CATEGORIES: readonly ReportCategory[] = [
"trash",
"recycling",
"graffiti",
"hazard",
"encampment",
"water",
"other",
]

const REPORT_TYPES: { id: ReportCategory; label: string; pin: string }[] = CATEGORIES.map(
(id) => ({ id, label: categoryLabel(id), pin: categoryPinSrc(id) }),
)
const REPORT_TYPES: { id: ReportCategory; label: string; pin: string; types: string }[] =
REPORT_CATEGORIES.map((id) => ({
id,
label: categoryLabel(id),
pin: categoryPinSrc(id),
types: categoryReportTypes(id),
}))

function routingCount(counts: PerCategoryCounts, id: ReportCategory): number {
return counts[id] ?? 0
Expand All @@ -110,7 +109,7 @@ function routingCount(counts: PerCategoryCounts, id: ReportCategory): number {
function dominantCategory(counts: PerCategoryCounts): ReportCategory | null {
let best: ReportCategory | null = null
let bestN = 0
for (const c of CATEGORIES) {
for (const c of REPORT_CATEGORIES) {
const n = counts[c] ?? 0
if (n > bestN) {
bestN = n
Expand Down Expand Up @@ -337,29 +336,31 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
)
}

const onSaveDraft = () => {
const normalizedHandle = handle.trim().replace(/^@+/, "").toLowerCase()
const handleChanged = normalizedHandle !== (dto.handle ?? "")

const noteAndHandleFields = () => {
const note = opNote.trim()
return {
...(note ? { notes: note } : {}),
...(handleChanged ? { handle: normalizedHandle } : {}),
}
}

const onSaveDraft = () => {
const contacts = contactsPayload()
const jf = jurisdictionFields()
const normalizedHandle = handle.trim().replace(/^@+/, "").toLowerCase()
const handleChanged = normalizedHandle !== (dto.handle ?? "")
const extras = noteAndHandleFields()
if (
Object.keys(contacts).length === 0 &&
Object.keys(jf).length === 0 &&
note === "" &&
!handleChanged
Object.keys(extras).length === 0
) {
toast("Nothing to save yet")
return
}
patch.mutate(
{
geoid: dto.geoid,
contacts,
...jf,
...(note ? { notes: note } : {}),
...(handleChanged ? { handle: normalizedHandle } : {}),
},
{ geoid: dto.geoid, contacts, ...jf, ...extras },
{
onSuccess: () => toast(`Draft saved for ${dto.org}`),
onError: (err) => toast(toAppError(err).message),
Expand All @@ -369,9 +370,25 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {

const onSaveContacts = () => {
if (!canSave) return
saveContacts.mutate(
{ geoid: dto.geoid, contacts: contactsPayload(), ...jurisdictionFields() },
{ onSuccess: () => toast(`Contacts saved for ${dto.org}`) },
const saveAndRoute = () =>
saveContacts.mutate(
{ geoid: dto.geoid, contacts: contactsPayload(), ...jurisdictionFields() },
{
onSuccess: () =>
toast(`Contacts saved for ${dto.org} · discovery task closed`),
},
)
const extras = noteAndHandleFields()
if (Object.keys(extras).length === 0) {
saveAndRoute()
return
}
patch.mutate(
{ geoid: dto.geoid, ...extras },
{
onSuccess: saveAndRoute,
onError: (err) => toast(toAppError(err).message),
},
)
}

Expand Down Expand Up @@ -485,10 +502,9 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
/>
</div>
<div className="hint" style={{ marginTop: 8 }}>
Residents can tag “@
{handle.trim().replace(/^@+/, "").toLowerCase() || "handle"}” in a report’s discussion to
forward it to this jurisdiction. Lowercase letters, numbers, and underscores; leave blank to
clear. Saved with “Save draft”.
Residents can tag “@{normalizedHandle || "handle"}” in a report’s discussion to forward it
to this jurisdiction. Lowercase letters, numbers, and underscores; leave blank to clear.
Saved by either “Save draft” or “Save &amp; route”.
</div>
</div>
</div>
Expand Down Expand Up @@ -554,6 +570,7 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
{n} {n === 1 ? "report" : "reports"}
</span>
</div>
{c.types && <div className="sub-caption">{c.types}</div>}
<div className="ccat-email">
<Icons.Mail size={13} />
<input
Expand All @@ -577,7 +594,8 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
})}
</div>
<div className="hint" style={{ marginTop: 10 }}>
Counts are reports waiting per type · highlighted types have reports but no contact yet.
Counts are reports waiting per category · the grey line lists the report types neighbors
pick that fold into it · highlighted categories have reports but no contact yet.
</div>
</div>
</div>
Expand Down Expand Up @@ -618,20 +636,39 @@ function JurisdictionDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
<button className="btn danger" disabled={patch.isPending} onClick={onFlag}>
<Icons.Flag size={13} /> {isFlagged ? "Clear flag" : "Flag for review"}
</button>
<button className="btn" disabled={busy} onClick={onSaveDraft}>
<button
className="btn"
disabled={busy}
onClick={onSaveDraft}
title="Saves the contacts, the note and the @handle without closing the discovery task"
>
Save draft
</button>
<button
className={`btn ${canSave ? "success" : ""}`}
disabled={!canSave || busy}
onClick={onSaveContacts}
title={canSave ? undefined : "Add at least one contact first"}
title={
canSave
? "Saves the contacts, the note and the @handle, closes the discovery task, and queues the outreach digest when outreach is enabled"
: "Add at least one contact first"
}
style={!canSave ? { opacity: 0.4, cursor: "not-allowed" } : undefined}
>
<Icons.Check size={13} /> Save contacts
<Icons.Check size={13} /> Save &amp; route
</button>
</div>

<div className="pay-note">
<Icons.Send size={13} />
<span>
<b>Save &amp; route</b> 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.{" "}
<b>Save draft</b> saves the same fields and leaves the discovery task open.
</span>
</div>

<ForwardTemplateModal
open={templateOpen}
onClose={() => setTemplateOpen(false)}
Expand Down Expand Up @@ -729,7 +766,7 @@ function UnmappedDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
}

export function DiscoveryPage({ focusId }: SectionPageProps) {
const [filter, setFilter] = React.useState<JurisdictionFilter>("all")
const [filter, setFilter] = React.useState<JurisdictionFilter>("attention")
const [layer, setLayer] = React.useState<"all" | JurisdictionLayer>("all")
const [sort, setSort] = React.useState<JurisdictionSort>("pop")
const [query, setQuery] = React.useState(focusId ?? "")
Expand Down Expand Up @@ -785,14 +822,14 @@ export function DiscoveryPage({ focusId }: SectionPageProps) {
const selected = focused ?? (holdingFocus ? null : (items[0] ?? null))

const catFilters = [
{ value: "all", label: "All", count: allTotal ?? 0 },
{ value: "attention", label: "Need mapping", count: needsMappingCountDisplay },
{ value: "attention", label: "Needs mapping", count: needsMappingCountDisplay },
{ value: "clear", label: "Routed", count: facets?.routed ?? 0 },
{ value: "all", label: "All", count: allTotal ?? 0 },
]

const onFilterChange = (v: JurisdictionFilter) => {
setFilter(v)
// The "need mapping" view is about the most-overdue reports first, so default it to the
// The "needs mapping" view is about the most-overdue reports first, so default it to the
// oldest-first sort; leaving the view falls back to population unless the operator picked reports.
if (v === "attention") setSort("oldest")
else if (sort === "oldest") setSort("pop")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"
import { getJurisdictionSort, getNeedsMappingCountDisplay } from "./discovery-ui-state"

describe("Jurisdictions UI state", () => {
it("always uses oldest sort for the Need mapping filter", () => {
it("always uses oldest sort for the Needs mapping filter", () => {
expect(getJurisdictionSort("attention", "pop")).toBe("oldest")
expect(getJurisdictionSort("attention", "reports")).toBe("oldest")
expect(getJurisdictionSort("attention", "oldest")).toBe("oldest")
Expand Down
5 changes: 2 additions & 3 deletions apps/admin/src/features/discovery/use-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { queryKeys } from "@/lib/query"
/**
* 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 + activity feeds
* 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,
Expand Down Expand Up @@ -89,12 +89,11 @@ export function useJurisdictionGeometry(geoid: string | null) {
})
}

/** Invalidate every discovery/jurisdiction view plus the home + activity aggregates after a write. */
/** Invalidate every discovery/jurisdiction view plus the home aggregates after a write. */
function invalidateDiscovery(qc: ReturnType<typeof useQueryClient>) {
qc.invalidateQueries({ queryKey: queryKeys.discovery.all })
qc.invalidateQueries({ queryKey: queryKeys.jurisdictions.all })
qc.invalidateQueries({ queryKey: queryKeys.home.all })
qc.invalidateQueries({ queryKey: queryKeys.activity.all })
}

/** POST /admin/discovery/:id/notes - append an operator note to a discovery task. */
Expand Down
Loading
Loading