diff --git a/.env.example b/.env.example deleted file mode 100644 index 8c625a7..0000000 --- a/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# civfix-admin (operator dashboard, admin.civfix.org) environment. -# -# NEXT_PUBLIC_* values are INLINED into the static export at BUILD time (not read at runtime). Set -# them before `pnpm build` (or `pnpm -C apps/admin build`). In CI they come from repo Variables -# (vars.*); see .github/workflows/deploy.yml. - -# Base URL of the civfix API. The dashboard is served at admin.civfix.org and calls this API for all -# /admin/* data and auth. The API's WEB_ORIGINS must include https://admin.civfix.org so CORS and the -# session cookie work. Defaults to the local backend when unset. -# Example (production): https://api.civfix.example -NEXT_PUBLIC_API_URL=http://localhost:8080 - -# Publishable CARTO basemap api key, appended to the Leaflet raster tile URLs. Optional: unset, the maps -# still render the same CARTO tiles, just with CARTO's watermark. Public by nature (it is inlined into -# the client bundle). -# NEXT_PUBLIC_CARTO_API_KEY= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33f2635..3883b43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,10 @@ name: CI -# main is the only long-lived branch and the only PR target (issue civfix/issue-tracker#108): main is -# the staging lane, and production ships from a published v* release. +# main is the only long-lived branch (issue civfix/issue-tracker#108): main is the staging lane, and +# production ships from a published v* release. No base filter: a stacked PR targets its parent slice's +# branch and still needs CI before it retargets onto main. on: pull_request: - branches: [main] # Cancel superseded runs on the same ref. concurrency: @@ -36,6 +36,12 @@ jobs: - name: Lint run: pnpm lint + - name: Unused files, exports and dependencies (knip) + run: pnpm --filter admin knip + + - name: Duplication (jscpd) + run: pnpm --filter admin dup + - name: Typecheck run: pnpm typecheck diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e0322c5..4de4087 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,8 +1,8 @@ name: Deploy (Cloudflare Pages) -# Builds the civfix-admin static export and publishes it to its Cloudflare Pages project on every -# push to main, plus on-demand via the Actions "Run workflow" button. One app ships from this repo: -# - admin -> Pages project "civfix-admin" (apps/admin/out, repo-root wrangler.jsonc) -> admin.civfix.org +# Builds the civfix-admin static export and publishes it to its Cloudflare Pages project "civfix-admin" +# (apps/admin/out, repo-root wrangler.jsonc). A push to main (or a manual run on main) deploys STAGING, +# admin.civfix.dev; a published v* release deploys PRODUCTION, admin.civfix.org (ref -> environment below). # # Required GitHub configuration (Settings -> Secrets and variables -> Actions): # @@ -11,21 +11,21 @@ name: Deploy (Cloudflare Pages) # CLOUDFLARE_ACCOUNT_ID The Cloudflare account id that owns the Pages project. # # Variables (vars.*), inlined into the static export at BUILD time (NEXT_PUBLIC_*): -# NEXT_PUBLIC_API_URL Base URL of the civfix API (e.g. https://api.civfix.example). The admin -# dashboard is served at admin.civfix.org and talks to this API. The API's -# WEB_ORIGINS must include https://admin.civfix.org for CORS + cookies. +# NEXT_PUBLIC_API_URL Leave unset: both environments call the API same-origin (see below). +# Setting it points the build at a cross-origin API instead. +# NEXT_PUBLIC_CARTO_API_KEY Optional publishable CARTO key for the basemap tiles. # # The project name + prebuilt output directory come from wrangler.jsonc, so the deploy step is just # `pages deploy` (no args). The project is created automatically by the first run. We use Direct Upload # (build here, ship the prebuilt out/) rather than Cloudflare's native Git build; @civfix/shared installs -# from the public-read private registry via the committed .npmrc, so no submodule checkout is needed. +# from the public-read private registry via the committed .npmrc. # SPA deep links and security/cache headers are handled by the app's public/{_redirects,_headers} # (copied into out/). # Ref -> environment (issue https://github.com/civfix/issue-tracker/issues/108): # push to main -> STAGING --branch=staging -> staging.civfix-admin.pages.dev -> admin.civfix.dev # (via the civfix-infra staging Caddy, which reverse-proxies the SPA to that -# alias — env/staging.sh ADMIN_SPA_UPSTREAM) +# alias: env/staging.sh ADMIN_SPA_UPSTREAM) # published v* tag -> PRODUCTION --branch=main -> civfix-admin.pages.dev -> admin.civfix.org # # There is no `dev` branch: feature branches PR into main, main IS the staging lane, and cutting a @@ -38,7 +38,7 @@ on: branches: [main] release: # `released`, NOT `published`: `published` also fires for a PRERELEASE, and a prerelease must never - # reach admin.civfix.org — the operator plane. The regex gate below is the second guard. + # reach admin.civfix.org, the operator plane. The regex gate below is the second guard. types: [released] workflow_dispatch: @@ -117,7 +117,7 @@ jobs: # hash that already includes the NEXT_PUBLIC_* vars, so a changed API URL or source file rebuilds. # The key is scoped by DEPLOY_ENV anyway: staging and production build the same commit, and # `.next/cache` is restored underneath Turbo, so the two must never share one. (Admin happens to - # build identically in both environments today — this keeps that from becoming load-bearing.) + # build identically in both environments today; this keeps that from becoming load-bearing.) - name: Restore Turbo + Next.js build cache uses: actions/cache@v4 with: diff --git a/.gitignore b/.gitignore index 2486917..26786cb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,10 +21,10 @@ dist/ npm-debug.log* pnpm-debug.log* -# Env files (never commit secrets) +# Env files (never commit secrets); only the documented example is tracked .env -.env.local -.env.*.local +.env.* +!.env.example # Editor / OS .DS_Store diff --git a/README.md b/README.md index deb7d8a..ee6c666 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ Workspace for the civfix ADMIN / OPERATOR dashboard, served at `admin.civfix.org`. A pnpm + Turbo monorepo. This is the internal tool operators use to triage reports, route jurisdiction contacts, -moderate content, run mail outreach, provision government accounts, and read analytics. It is a -sibling of `civfix-shared`, `civfix-backend`, `civfix-web`, and `civfix-mobile`. +moderate content, run mail outreach, provision government accounts, and read analytics. Its siblings +are `civfix-app` (community web + mobile, and the `@civfix/shared` contract), `civfix-backend` (the +API it calls) and `civfix-infra` (the edge that serves it). ## Layout @@ -18,9 +19,9 @@ civfix-admin/ `@civfix/shared` (the shared contract package) is installed from the private Verdaccio registry at `https://repo.civfix.org`. The repo-root `.npmrc` scopes `@civfix` to it and the app depends on a -published version (`^0.1.0` today); the registry allows anonymous read, so no credentials are needed. -After cloning, `pnpm install` fetches it — there is no submodule to initialize. The published tarball -already contains the built `dist`, so no local build of the contract is required. +published version by caret range (see `apps/admin/package.json`); the registry allows anonymous read, so +no credentials are needed. After cloning, `pnpm install` fetches it. The published tarball already +contains the built `dist`, so no local build of the contract is required. ## Commands (from this root) @@ -28,18 +29,24 @@ already contains the built `dist`, so no local build of the contract is required pnpm install # fetches @civfix/shared from repo.civfix.org pnpm build # admin (next build, static export -> apps/admin/out) pnpm typecheck -pnpm lint +pnpm lint # eslint (flat config, typed rules) over apps/admin +pnpm test # vitest unit tests +pnpm --filter admin knip # unused files, exports and dependencies +pnpm --filter admin dup # jscpd duplication gate (app code and tests measured separately) pnpm dev # runs the dashboard dev server (admin) ``` ## Auth + API -The dashboard talks to the civfix API at `NEXT_PUBLIC_API_URL` (inlined at build time; see -`.env.example`). Operator sign-in reuses the civfix Email-OTP flow through the allowlist-gated -`/admin/auth/*` routes; only emails in the backend `ADMIN_EMAILS` allowlist can receive a code. The -shell renders only after an authenticated `operator` session exists; otherwise the login gate shows. -Cookies + CSRF + `x-client: web` are sent exactly as the public web app does. The API's -`WEB_ORIGINS` must include `https://admin.civfix.org`. +In production the dashboard calls the API same-origin: `admin.civfix.org` sits behind Cloudflare Access, +and the edge serves the SPA and proxies `/v1/admin/*` to the backend, so `NEXT_PUBLIC_API_URL` stays +unset. A development build defaults to `http://localhost:8080`; see `apps/admin/.env.example`. + +Operator sign-in is the Cloudflare Access exchange: the dashboard first reuses an existing operator +session, and otherwise posts to the admin Access-exchange route, which trades the Access identity the +edge attached for an `operator` session. An Access identity whose email is not on the operator +allowlist gets the "forbidden" screen. The shell renders only after an operator session exists. +Cookies, CSRF and `x-client: web` are sent exactly as the public web app sends them. ## Deploy @@ -47,8 +54,8 @@ Cloudflare Pages (Direct Upload). GitHub Actions builds the static export (a pla `pnpm install`, which fetches `@civfix/shared` from `repo.civfix.org`; pinned Node 22 + pnpm 9.12.0) and ships the prebuilt `apps/admin/out` via `wrangler pages deploy`. The Pages project name and output dir come from the repo-root `wrangler.jsonc` -(`civfix-admin` -> `apps/admin/out`). See `.github/workflows/deploy.yml` and -`../documents/phase2/07-civfix-admin-repo.md`. +(`civfix-admin` -> `apps/admin/out`). A push to `main` deploys staging (`admin.civfix.dev`); a published +`v*` release deploys production (`admin.civfix.org`). See `.github/workflows/deploy.yml`. ## License @@ -58,6 +65,6 @@ covered by the declaration in [REUSE.toml](REUSE.toml); there are no per-file license headers. The dashboard honors the AGPL's source offer with the "Source code" link on its home screen and sign-in screen, which points at the deployed commit. Contributions are accepted under the -[Contributor License Agreement](CLA.md) — see +[Contributor License Agreement](CLA.md); see [CONTRIBUTING.md](CONTRIBUTING.md). civfix is a project of Reach Out Los Angeles Inc.; the civfix name and logos are its trademarks and are not covered by the license. diff --git a/apps/admin/.env.example b/apps/admin/.env.example index 2ac3ae0..25b2706 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -1,14 +1,12 @@ # civfix admin / operator dashboard environment (admin.civfix.org). # -# NEXT_PUBLIC_* values are INLINED into the static export at BUILD time (not read at runtime). Set -# them before `pnpm -C apps/admin build` (or `pnpm build` from the repo root). In CI they come from -# repo Variables (vars.*); see .github/workflows/deploy.yml. +# NEXT_PUBLIC_* values are INLINED into the static export at BUILD time, not read at runtime. Set them +# before `pnpm build`. In CI they come from repo Variables (vars.*); see .github/workflows/deploy.yml. -# Base URL of the civfix API. The dashboard is served at admin.civfix.org and calls this API for all -# /admin/* data and auth; the API's WEB_ORIGINS must include https://admin.civfix.org. Defaults to the -# local backend when unset. -# Example (production): https://api.civfix.example -NEXT_PUBLIC_API_URL=http://localhost:8080 +# Base URL of the civfix API. Leave it unset for staging and production: there the dashboard calls the +# API same-origin, because the edge serves the SPA and proxies /v1/admin/* to the backend behind the +# same Cloudflare Access app. When unset, a development build (`pnpm dev`) calls http://localhost:8080. +# NEXT_PUBLIC_API_URL=http://localhost:8080 # Publishable CARTO basemap api key, appended to the Leaflet raster tile URLs. Optional: unset, the maps # still render the same CARTO tiles, just with CARTO's watermark. Public by nature (it is inlined into diff --git a/apps/admin/.eslintrc.json b/apps/admin/.eslintrc.json deleted file mode 100644 index b79b149..0000000 --- a/apps/admin/.eslintrc.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript"], - "rules": { - "@typescript-eslint/consistent-type-imports": [ - "warn", - { "prefer": "type-imports", "fixStyle": "separate-type-imports" } - ], - "@typescript-eslint/no-unused-vars": [ - "warn", - { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } - ] - } -} diff --git a/apps/admin/.jscpd.json b/apps/admin/.jscpd.json new file mode 100644 index 0000000..3ac1b8b --- /dev/null +++ b/apps/admin/.jscpd.json @@ -0,0 +1,7 @@ +{ + "path": ["src"], + "format": ["typescript", "tsx", "css"], + "ignore": ["**/*.test.ts", "**/*.test.tsx", "src/test/**"], + "reporters": ["console"], + "threshold": 1 +} diff --git a/apps/admin/.jscpd.tests.json b/apps/admin/.jscpd.tests.json new file mode 100644 index 0000000..93a35e6 --- /dev/null +++ b/apps/admin/.jscpd.tests.json @@ -0,0 +1,7 @@ +{ + "path": ["src"], + "format": ["typescript", "tsx"], + "pattern": "{**/*.test.ts,**/*.test.tsx,src/test/**}", + "reporters": ["console"], + "threshold": 6 +} diff --git a/apps/admin/eslint.config.mjs b/apps/admin/eslint.config.mjs new file mode 100644 index 0000000..404c6a6 --- /dev/null +++ b/apps/admin/eslint.config.mjs @@ -0,0 +1,49 @@ +import { dirname } from "node:path" +import { fileURLToPath } from "node:url" + +import { FlatCompat } from "@eslint/eslintrc" + +const baseDirectory = dirname(fileURLToPath(import.meta.url)) + +// eslint-config-next 15.x ships only eslintrc presets; FlatCompat is the supported bridge until 16. +const compat = new FlatCompat({ baseDirectory }) + +const config = [ + { + ignores: [".next/**", "out/**", "coverage/**", "next-env.d.ts"], + }, + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + rules: { + // images.unoptimized is set for the static export, so next/image would render a plain anyway. + "@next/next/no-img-element": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + }, + }, + { + files: ["**/*.ts", "**/*.tsx"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: baseDirectory, + }, + }, + rules: { + "@typescript-eslint/consistent-type-imports": [ + "error", + { prefer: "type-imports", fixStyle: "separate-type-imports" }, + ], + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/no-misused-promises": [ + "error", + { checksVoidReturn: { attributes: false } }, + ], + "@typescript-eslint/switch-exhaustiveness-check": "error", + }, + }, +] + +export default config diff --git a/apps/admin/knip.config.ts b/apps/admin/knip.config.ts new file mode 100644 index 0000000..cfe22bc --- /dev/null +++ b/apps/admin/knip.config.ts @@ -0,0 +1,28 @@ +import type { KnipConfig } from "knip" + +const CSS_IMPORT = /@import\s+(?:url\(\s*)?["']([^"']+)["']/g + +const config: KnipConfig = { + project: ["src/**/*.{ts,tsx,css}", "*.{ts,mjs}"], + next: { + config: ["next.config.mjs"], + entry: ["src/app/**/{layout,page,not-found,error,loading}.tsx"], + }, + vitest: { + config: ["vitest.config.ts"], + entry: ["src/**/*.test.{ts,tsx}", "vitest.setup.ts"], + }, + // Knip skips any extension it cannot compile, so without this an orphaned stylesheet is never + // reported; following @import is what keeps colors-and-type.css (reached only from admin.css) used. + compilers: { + css: (text: string) => + [...text.matchAll(CSS_IMPORT)].map(([, specifier]) => `import "${specifier}";`).join("\n"), + }, + ignoreDependencies: [ + // Loaded by name through FlatCompat.extends("next/...") in eslint.config.mjs, which knip's ESLint + // plugin cannot see into. + "eslint-config-next", + ], +} + +export default config diff --git a/apps/admin/next.config.mjs b/apps/admin/next.config.mjs index fc01b45..fdfcb68 100644 --- a/apps/admin/next.config.mjs +++ b/apps/admin/next.config.mjs @@ -34,7 +34,6 @@ const nextConfig = { env: { NEXT_PUBLIC_COMMIT_SHA: resolveCommitSha(), }, - // The @civfix/shared package ships ESM + CJS from the workspace; let Next transpile it. transpilePackages: ["@civfix/shared"], } diff --git a/apps/admin/package.json b/apps/admin/package.json index a94950c..4ccdc45 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -8,25 +8,24 @@ "scripts": { "dev": "next dev", "build": "next build", - "start": "next start", - "lint": "next lint --max-warnings=0", + "lint": "eslint . --max-warnings=0", "typecheck": "tsc --noEmit", "test": "vitest run", - "clean": "rimraf .next out *.tsbuildinfo" + "clean": "rimraf .next out *.tsbuildinfo", + "knip": "knip", + "dup": "jscpd -c .jscpd.json && jscpd -c .jscpd.tests.json" }, "dependencies": { "@civfix/shared": "^0.57.0", "@tanstack/react-query": "^5.62.7", - "clsx": "^2.1.1", "leaflet": "^1.9.4", - "lucide-react": "^0.469.0", "next": "15.5.25", "react": "19.0.0", "react-dom": "19.0.0", - "tailwind-merge": "^2.6.0", "zustand": "^5.0.2" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.5", "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", @@ -38,11 +37,12 @@ "autoprefixer": "^10.4.20", "eslint": "^9.17.0", "eslint-config-next": "15.5.25", + "jscpd": "^5.3.2", "jsdom": "^26.1.0", + "knip": "^6.38.0", "postcss": "^8.5.28", "rimraf": "^6.0.1", "tailwindcss": "^3.4.17", - "tailwindcss-animate": "^1.0.7", "typescript": "^5.7.2", "vitest": "^3.2.7" } diff --git a/apps/admin/postcss.config.mjs b/apps/admin/postcss.config.mjs index d0c615b..393a10f 100644 --- a/apps/admin/postcss.config.mjs +++ b/apps/admin/postcss.config.mjs @@ -1,4 +1,3 @@ -/** @type {import('postcss-load-config').Config} */ const config = { plugins: { tailwindcss: {}, diff --git a/apps/admin/public/ds/pin-cleanup.svg b/apps/admin/public/ds/pin-cleanup.svg deleted file mode 100644 index 1b519be..0000000 --- a/apps/admin/public/ds/pin-cleanup.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/apps/admin/src/app/globals.css b/apps/admin/src/app/globals.css index 48648ef..348e072 100644 --- a/apps/admin/src/app/globals.css +++ b/apps/admin/src/app/globals.css @@ -5,14 +5,13 @@ * 1. Leaflet's stylesheet (the dashboard maps use Leaflet + CARTO raster tiles, matching the * design prototype exactly - a deliberate design-fidelity choice for this internal tool; it does * NOT use MapLibre like community-web). - * 2. Tailwind's base/components/utilities. Tailwind here provides only incidental LAYOUT utilities; - * the dashboard's full visual system is the ported design CSS imported in the root layout AFTER - * this file (src/styles/admin.css, which @imports src/styles/colors-and-type.css). Because the - * design CSS is imported after globals.css, its component classes win over Tailwind's base reset - * wherever they overlap on the shell. + * 2. Tailwind, which contributes only its base reset; the markup uses no intended Tailwind + * utilities. The dashboard's visual system is the ported design CSS imported in the root layout + * AFTER this file (src/styles/admin.css, which @imports src/styles/colors-and-type.css), so its + * component classes win over the reset wherever they overlap. * * Fonts: next/font assigns --font-display-next / --font-body-next / --font-mono-next on - * (layout.tsx) — exactly the names the ported design stylesheet (colors-and-type.css) aliases into the + * (layout.tsx), exactly the names the ported design stylesheet (colors-and-type.css) aliases into the * final --font-display / --font-body / --font-mono stacks. No bridge is needed (and a * `--font-*-next: var(--font-*)` bridge would be CIRCULAR, invalidating font-family -> serif fallback). */ @@ -43,27 +42,6 @@ } } -@layer utilities { - /* Hide scrollbars on scroll areas while keeping them scrollable. */ - .no-scrollbar { - -ms-overflow-style: none; - scrollbar-width: none; - } - .no-scrollbar::-webkit-scrollbar { - display: none; - } - - /* Small inline spinner for loading buttons / hooks (lucide Loader2 + this class). */ - .cf-spin { - animation: cf-spin 0.8s linear infinite; - } - @keyframes cf-spin { - to { - transform: rotate(360deg); - } - } -} - /* Leaflet canvas should inherit the body font so attributions match the design. */ .leaflet-container { font-family: var(--font-body); diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 512bd02..e4fa734 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -37,6 +37,7 @@ export const metadata: Metadata = { title: "civfix Operations", description: "civfix admin and operator dashboard: discovery, reports, events, mail, users, and analytics.", applicationName: "civfix Operations", + icons: { icon: "/favicon.svg" }, robots: { index: false, follow: false }, } diff --git a/apps/admin/src/components/map/boundary-map.tsx b/apps/admin/src/components/map/boundary-map.tsx index 9035e06..d6ffc0b 100644 --- a/apps/admin/src/components/map/boundary-map.tsx +++ b/apps/admin/src/components/map/boundary-map.tsx @@ -3,21 +3,13 @@ import * as React from "react" import L from "leaflet" -import { withCartoKey } from "@/lib/carto" -import { MAP_SETTLE_MS } from "@/lib/timing" +import { addCartoBasemap, useLeafletMap } from "@/components/map/leaflet-base" // Derived from Leaflet's own signature, so no direct @types/geojson dependency is needed. -type LeafletGeoJson = Parameters[0] +type LeafletGeoJson = Parameters[0] // 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"), - attribution: - '© OpenStreetMap © CARTO', - subdomains: "abcd", -} - const TILE_MAX_ZOOM = 19 // maxZoom caps how far the fit zooms in, so a tiny place still shows its surroundings. const FIT_OPTIONS: L.FitBoundsOptions = { padding: [12, 12], maxZoom: 13 } @@ -41,51 +33,35 @@ interface BoundaryMapProps { export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProps) { const elRef = React.useRef(null) - const mapRef = React.useRef(null) const shapeRef = React.useRef(null) - React.useEffect(() => { - if (!elRef.current || mapRef.current) return - const map = L.map(elRef.current, { - zoomControl: true, - attributionControl: true, - // The map sits inside a scrollable detail panel, so the wheel must scroll the panel. - scrollWheelZoom: false, - }) - mapRef.current = map - L.tileLayer(TILE.url, { - attribution: TILE.attribution, - subdomains: TILE.subdomains, - maxZoom: TILE_MAX_ZOOM, - detectRetina: true, - }).addTo(map) - - const resizeObserver = new ResizeObserver(() => map.invalidateSize()) - resizeObserver.observe(elRef.current) - const settleTimer = setTimeout(() => map.invalidateSize(), MAP_SETTLE_MS) - - return () => { - resizeObserver.disconnect() - clearTimeout(settleTimer) - map.remove() - mapRef.current = null + const mapRef = useLeafletMap( + elRef, + (container) => { + const map = L.map(container, { + zoomControl: true, + attributionControl: true, + // The map sits inside a scrollable detail panel, so the wheel must scroll the panel. + scrollWheelZoom: false, + }) + addCartoBasemap(map, { maxZoom: TILE_MAX_ZOOM }) + shapeRef.current = L.geoJSON().addTo(map) + return map + }, + () => { shapeRef.current = null - } - }, []) + }, + ) React.useEffect(() => { const map = mapRef.current - if (!map) return - if (shapeRef.current) { - shapeRef.current.remove() - shapeRef.current = null - } + const shape = shapeRef.current + if (!map || !shape) return const color = LAYER_COLOR[layer] ?? LAYER_COLOR.place - // L.geoJSON accepts a bare GeoJSON geometry object. - const shape = L.geoJSON(geometry as unknown as LeafletGeoJson, { - style: { color, weight: SHAPE_WEIGHT, fillColor: color, fillOpacity: SHAPE_FILL_OPACITY }, - }).addTo(map) - shapeRef.current = shape + // addData styles each new layer from options.style, so the style is set before the data goes in. + shape.options.style = { color, weight: SHAPE_WEIGHT, fillColor: color, fillOpacity: SHAPE_FILL_OPACITY } + // L.GeoJSON accepts a bare GeoJSON geometry object. + shape.clearLayers().addData(geometry as unknown as LeafletGeoJson) // bbox is [west, south, east, north]; Leaflet bounds are [[south, west], [north, east]]. const [west, south, east, north] = bbox @@ -96,7 +72,7 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp ], FIT_OPTIONS, ) - }, [geometry, bbox, layer]) + }, [mapRef, geometry, bbox, layer]) return
} diff --git a/apps/admin/src/components/map/leaflet-base.ts b/apps/admin/src/components/map/leaflet-base.ts new file mode 100644 index 0000000..7933858 --- /dev/null +++ b/apps/admin/src/components/map/leaflet-base.ts @@ -0,0 +1,79 @@ +import * as React from "react" +import L from "leaflet" + +import { withCartoKey } from "@/lib/carto" +import { MAP_SETTLE_MS } from "@/lib/timing" + +const CARTO_ATTRIBUTION = + '© OpenStreetMap © CARTO' + +const CARTO_TILE_URLS = { + voyager: withCartoKey("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"), + positron: withCartoKey("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"), +} as const + +export type CartoTint = keyof typeof CARTO_TILE_URLS + +function cartoUrl(tint: CartoTint): string { + return CARTO_TILE_URLS[tint] ?? CARTO_TILE_URLS.voyager +} + +export function addCartoBasemap( + map: L.Map, + { tint = "voyager", maxZoom }: { tint?: CartoTint; maxZoom: number }, +): L.TileLayer { + return L.tileLayer(cartoUrl(tint), { + attribution: CARTO_ATTRIBUTION, + subdomains: "abcd", + maxZoom, + detectRetina: true, + }).addTo(map) +} + +// Every tint shares the attribution, subdomains and zoom range, so only the URL template differs; +// setUrl is a no-op when the URL is unchanged. +export function setCartoTint(layer: L.TileLayer, tint: CartoTint): void { + layer.setUrl(cartoUrl(tint)) +} + +function attachResize(map: L.Map, container: HTMLElement): () => void { + const resizeObserver = new ResizeObserver(() => map.invalidateSize()) + resizeObserver.observe(container) + const settleTimer = setTimeout(() => map.invalidateSize(), MAP_SETTLE_MS) + return () => { + resizeObserver.disconnect() + clearTimeout(settleTimer) + } +} + +/** + * Creates the map once on mount with `create`, keeps it sized to its container, and removes it on + * unmount, after which `onRemove` resets whatever layer state the caller holds for it. Declare the + * caller's own effects after this hook so they run against the created map. + */ +export function useLeafletMap( + containerRef: React.RefObject, + create: (container: HTMLDivElement) => L.Map, + onRemove?: () => void, +): React.RefObject { + const mapRef = React.useRef(null) + + React.useEffect(() => { + const container = containerRef.current + if (!container || mapRef.current) return + const map = create(container) + mapRef.current = map + const detachResize = attachResize(map, container) + + return () => { + detachResize() + map.remove() + mapRef.current = null + onRemove?.() + } + // Created once; callers' own effects apply later prop changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [containerRef]) + + return mapRef +} diff --git a/apps/admin/src/components/map/leaflet-map.tsx b/apps/admin/src/components/map/leaflet-map.tsx index f75afed..07d1aa9 100644 --- a/apps/admin/src/components/map/leaflet-map.tsx +++ b/apps/admin/src/components/map/leaflet-map.tsx @@ -4,9 +4,8 @@ import * as React from "react" import L from "leaflet" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" -import { withCartoKey } from "@/lib/carto" +import { addCartoBasemap, setCartoTint, useLeafletMap, type CartoTint } from "@/components/map/leaflet-base" import { CATEGORY_GLYPHS } from "@/lib/category" -import { MAP_SETTLE_MS } from "@/lib/timing" // 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. @@ -27,36 +26,11 @@ export interface MapPin { place?: string } -const MAP_TILES = { - voyager: { - url: withCartoKey("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"), - attribution: - '© OpenStreetMap © CARTO', - subdomains: "abcd", - }, - positron: { - url: withCartoKey("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"), - attribution: - '© OpenStreetMap © CARTO', - subdomains: "abcd", - }, -} as const - -export type MapTint = keyof typeof MAP_TILES +export type MapTint = CartoTint const MAP_HOME = { center: [39.5, -98.35] as [number, number], zoom: 4 } const BASEMAP_MAX_ZOOM = 20 -function addBasemap(map: L.Map, tint: MapTint): L.TileLayer { - const tiles = MAP_TILES[tint] ?? MAP_TILES.voyager - return L.tileLayer(tiles.url, { - attribution: tiles.attribution, - subdomains: tiles.subdomains, - maxZoom: BASEMAP_MAX_ZOOM, - detectRetina: true, - }).addTo(map) -} - const PIN_VIEWBOX_WIDTH = 64 const PIN_VIEWBOX_HEIGHT = 76 // Centers the 24-unit glyph (the icon set's grid) in the teardrop's head. @@ -257,7 +231,6 @@ export function LeafletMap({ onPinTap, }: LeafletMapProps) { const elRef = React.useRef(null) - const mapRef = React.useRef(null) const tileRef = React.useRef(null) const markersRef = React.useRef>({}) const renderRef = React.useRef>({}) @@ -267,56 +240,43 @@ export function LeafletMap({ const onPinTapRef = React.useRef(onPinTap) onPinTapRef.current = onPinTap - React.useEffect(() => { - if (!elRef.current || mapRef.current) return - const map = L.map(elRef.current, { - center, - zoom, - zoomControl: false, - attributionControl: true, - dragging: interactive, - scrollWheelZoom: interactive, - doubleClickZoom: interactive, - touchZoom: interactive, - boxZoom: false, - keyboard: false, - // No `tap`: the legacy option is a no-op in modern Leaflet and gone from @types/leaflet. - }) - mapRef.current = map - - tileRef.current = addBasemap(map, tint) - - if (interactive) { - L.control.zoom({ position: "topright" }).addTo(map) - } - - const resizeObserver = new ResizeObserver(() => map.invalidateSize()) - resizeObserver.observe(elRef.current) - const settleTimer = setTimeout(() => map.invalidateSize(), MAP_SETTLE_MS) - - return () => { - resizeObserver.disconnect() - clearTimeout(settleTimer) - map.remove() - mapRef.current = null + const mapRef = useLeafletMap( + elRef, + (container) => { + const map = L.map(container, { + center, + zoom, + zoomControl: false, + attributionControl: true, + dragging: interactive, + scrollWheelZoom: interactive, + doubleClickZoom: interactive, + touchZoom: interactive, + boxZoom: false, + keyboard: false, + // No `tap`: the legacy option is a no-op in modern Leaflet and gone from @types/leaflet. + }) + tileRef.current = addCartoBasemap(map, { tint, maxZoom: BASEMAP_MAX_ZOOM }) + if (interactive) { + L.control.zoom({ position: "topright" }).addTo(map) + } + return map + }, + () => { + tileRef.current = null markersRef.current = {} renderRef.current = {} pinsRef.current = {} - } - // Created once; the effects below apply later prop changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, + ) const [centerLat, centerLng] = center React.useEffect(() => { mapRef.current?.setView([centerLat, centerLng], zoom) - }, [centerLat, centerLng, zoom]) + }, [mapRef, centerLat, centerLng, zoom]) React.useEffect(() => { - const map = mapRef.current - if (!map) return - if (tileRef.current) tileRef.current.remove() - tileRef.current = addBasemap(map, tint) + if (tileRef.current) setCartoTint(tileRef.current, tint) }, [tint]) React.useEffect(() => { @@ -330,7 +290,7 @@ export function LeafletMap({ latestPins: pinsRef.current, onTap: (pin) => onPinTapRef.current?.(pin), }) - }, [pins, activeId, interactive]) + }, [mapRef, pins, activeId, interactive]) return
} diff --git a/apps/admin/src/components/map/live-map.test.tsx b/apps/admin/src/components/map/live-map.test.tsx index 73aa9a5..2dcfc13 100644 --- a/apps/admin/src/components/map/live-map.test.tsx +++ b/apps/admin/src/components/map/live-map.test.tsx @@ -99,7 +99,9 @@ describe("LiveMap active card", () => { await tap(user, "report-r1") expect(screen.getByText("Tagged wall")).toBeInTheDocument() - act(() => client.setQueryData(queryKeys.home.map, { pins: [reportPin({ title: "Wall repainted" })] })) + act(() => { + client.setQueryData(queryKeys.home.map, { pins: [reportPin({ title: "Wall repainted" })] }) + }) expect(await screen.findByText("Wall repainted")).toBeInTheDocument() expect(screen.queryByText("Tagged wall")).toBeNull() }) @@ -108,7 +110,9 @@ describe("LiveMap active card", () => { const { user, client } = await renderMap([reportPin(), eventPin()]) await tap(user, "report-r1") - act(() => client.setQueryData(queryKeys.home.map, { pins: [eventPin()] })) + act(() => { + client.setQueryData(queryKeys.home.map, { pins: [eventPin()] }) + }) await waitFor(() => expect(screen.queryByText("Tagged wall")).toBeNull()) expect(screen.queryByRole("button", { name: /Open report/ })).toBeNull() }) diff --git a/apps/admin/src/components/map/live-map.tsx b/apps/admin/src/components/map/live-map.tsx index fa34cb2..80fbf3b 100644 --- a/apps/admin/src/components/map/live-map.tsx +++ b/apps/admin/src/components/map/live-map.tsx @@ -5,7 +5,7 @@ import dynamic from "next/dynamic" import type { HomeMapPin } from "@civfix/shared" import { Icons } from "@/components/icons" -import { useHomeMap } from "@/hooks/use-admin-home" +import { useHomeMap } from "@/features/home/use-home" import { useNav } from "@/store/ui-store" import { categoryLabel } from "@/lib/category" import { EVENT_KIND_PIN_KIND, eventKindLabel } from "@/lib/event-kind" diff --git a/apps/admin/src/components/shared/brand-bug.tsx b/apps/admin/src/components/shared/brand-bug.tsx index f036306..3a3ba38 100644 --- a/apps/admin/src/components/shared/brand-bug.tsx +++ b/apps/admin/src/components/shared/brand-bug.tsx @@ -3,6 +3,5 @@ const BRAND_BUG_SRC = "/ds/pinit-bug.svg" // Decorative: every caller sets the brand name in text beside it or hides it from assistive tech. export function BrandBug({ width, height }: { width: number; height: number }) { // A tiny static SVG: next/image adds nothing under the static export's unoptimized images. - // eslint-disable-next-line @next/next/no-img-element return } diff --git a/apps/admin/src/components/shared/dialog.tsx b/apps/admin/src/components/shared/dialog.tsx index 96f32b4..a6a5c63 100644 --- a/apps/admin/src/components/shared/dialog.tsx +++ b/apps/admin/src/components/shared/dialog.tsx @@ -69,6 +69,16 @@ export function promptDialog( }) } +const REASON_LABEL = "Reason (required)" + +export async function promptReason( + opts: Omit, +): Promise { + const reason = await promptDialog({ ...opts, label: REASON_LABEL, required: true }) + const trimmed = reason?.trim() ?? "" + return trimmed === "" ? null : trimmed +} + const PROMPT_FIELD_ROWS = 3 const NATIVE_ENTER_TAGS = new Set(["BUTTON", "A", "SELECT"]) diff --git a/apps/admin/src/components/shared/lightbox.tsx b/apps/admin/src/components/shared/lightbox.tsx index 2839634..96b39ef 100644 --- a/apps/admin/src/components/shared/lightbox.tsx +++ b/apps/admin/src/components/shared/lightbox.tsx @@ -165,7 +165,6 @@ export function LightboxHost() {
{status !== "failed" && ( - // eslint-disable-next-line @next/next/no-img-element undefined } + +function nextPage(overrides: Partial<{ hasNextPage: boolean; isFetchingNextPage: boolean }> = {}) { + return { hasNextPage: true, isFetchingNextPage: false, fetchNextPage: vi.fn(), ...overrides } +} + +describe("ListStates", () => { + it("shows the loading label while loading", () => { + render( + +

rows

+
, + ) + expect(screen.getByRole("status")).toHaveTextContent("Loading rows...") + expect(screen.queryByText("rows")).toBeNull() + }) + + it("shows the error with a retry that refetches", () => { + const refetch = vi.fn() + render( + +

rows

+
, + ) + expect(screen.getByRole("alert")).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(refetch).toHaveBeenCalledOnce() + }) + + it("shows the empty state only when empty", () => { + const { rerender } = render( + nothing

}> +

rows

+
, + ) + expect(screen.getByText("nothing")).toBeInTheDocument() + expect(screen.queryByText("rows")).toBeNull() + + rerender( + nothing

}> +

rows

+
, + ) + expect(screen.getByText("rows")).toBeInTheDocument() + expect(screen.queryByText("nothing")).toBeNull() + }) +}) + +describe("LoadMoreButton", () => { + it("renders nothing without a next page", () => { + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) + + it("fetches the next page on click", () => { + const query = nextPage() + render() + const button = screen.getByRole("button", { name: "Load more" }) + expect(button).toHaveClass("btn", "list-load-more") + expect(button).not.toHaveAttribute("style") + fireEvent.click(button) + expect(query.fetchNextPage).toHaveBeenCalledOnce() + }) + + it("is disabled while the next page loads", () => { + render() + expect(screen.getByRole("button", { name: "Loading…" })).toBeDisabled() + }) + + it("takes a custom label", () => { + render() + expect(screen.getByRole("button", { name: "Load more broadcasts" })).toHaveClass("btn", "load-more") + }) +}) + +describe("ListCard", () => { + it("renders the card head and list body", () => { + const { container } = render( + +

rows

+
, + ) + expect(container.innerHTML).toBe( + '

Events

' + + '0

rows

', + ) + }) + + it("omits the meta when it is undefined", () => { + const { container } = render( + +

rows

+
, + ) + expect(container.querySelector(".meta")).toBeNull() + }) +}) + +describe("SearchBox", () => { + it("reports the typed value", () => { + const onChange = vi.fn() + render() + fireEvent.change(screen.getByRole("textbox", { name: "Search events" }), { target: { value: "park" } }) + expect(onChange).toHaveBeenCalledWith("park") + }) +}) diff --git a/apps/admin/src/components/shared/section-list.tsx b/apps/admin/src/components/shared/section-list.tsx new file mode 100644 index 0000000..89d96b9 --- /dev/null +++ b/apps/admin/src/components/shared/section-list.tsx @@ -0,0 +1,106 @@ +"use client" + +import type * as React from "react" + +import { Icons } from "@/components/icons" +import { LoadingState, ErrorState } from "@/components/shared/data-states" + +export interface ListLoadState { + isLoading: boolean + isError: boolean + error: unknown + refetch: () => unknown +} + +export interface NextPageState { + hasNextPage: boolean + isFetchingNextPage: boolean + fetchNextPage: () => unknown +} + +export function SearchBox({ + label, + placeholder, + value, + onChange, +}: { + label: string + placeholder: string + value: string + onChange: (value: string) => void +}) { + return ( +
+ + onChange(e.target.value)} + /> +
+ ) +} + +export function ListCard({ + title, + meta, + children, +}: { + title: React.ReactNode + meta?: React.ReactNode + children: React.ReactNode +}) { + return ( +
+
+

{title}

+
+ {meta !== undefined && {meta}} +
+
{children}
+
+ ) +} + +export function ListStates({ + query, + loadingLabel, + isEmpty = false, + empty, + children, +}: { + query: ListLoadState + loadingLabel: string + isEmpty?: boolean + empty?: React.ReactNode + children: React.ReactNode +}) { + if (query.isLoading) return + if (query.isError) return query.refetch()} /> + if (isEmpty) return empty + return children +} + +export function LoadMoreButton({ + query, + className, + label = "Load more", +}: { + query: NextPageState + className: "list-load-more" | "load-more" + label?: string +}) { + if (!query.hasNextPage) return null + return ( + + ) +} diff --git a/apps/admin/src/components/shell/back-bar.tsx b/apps/admin/src/components/shell/back-bar.tsx index af640b5..9e67104 100644 --- a/apps/admin/src/components/shell/back-bar.tsx +++ b/apps/admin/src/components/shell/back-bar.tsx @@ -2,7 +2,7 @@ import { Icons, type IconComponent } from "@/components/icons" import { PAGE_LABEL, useNav, type SectionId } from "@/store/ui-store" -import { useAdminLogout, useOperatorSession } from "@/hooks/use-admin-auth" +import { useOperatorLogout, useOperatorSession } from "@/hooks/use-admin-auth" const SECTION_ICON: Record = { discovery: Icons.Pin, @@ -33,7 +33,7 @@ const SECTION_HUE: Record = { export function BackBar({ page }: { page: SectionId }) { const nav = useNav() const { operator } = useOperatorSession() - const logout = useAdminLogout() + const logout = useOperatorLogout() const SectionIcon = SECTION_ICON[page] const hue = SECTION_HUE[page] return ( diff --git a/apps/admin/src/components/shell/toast.test.tsx b/apps/admin/src/components/shell/toast.test.tsx index 0c40eb3..02348a7 100644 --- a/apps/admin/src/components/shell/toast.test.tsx +++ b/apps/admin/src/components/shell/toast.test.tsx @@ -108,9 +108,13 @@ describe("Toast", () => { try { render() show("Saved") - act(() => vi.advanceTimersByTime(SUCCESS_TOAST_MS - 1)) + act(() => { + vi.advanceTimersByTime(SUCCESS_TOAST_MS - 1) + }) expect(screen.getByRole("status")).toHaveTextContent("Saved") - act(() => vi.advanceTimersByTime(1)) + act(() => { + vi.advanceTimersByTime(1) + }) expect(useUiStore.getState().toast).toBeNull() } finally { vi.useRealTimers() @@ -122,9 +126,13 @@ describe("Toast", () => { try { render() show("Could not save", "error") - act(() => vi.advanceTimersByTime(SUCCESS_TOAST_MS)) + act(() => { + vi.advanceTimersByTime(SUCCESS_TOAST_MS) + }) expect(screen.getByRole("status")).toHaveTextContent("Could not save") - act(() => vi.advanceTimersByTime(ERROR_TOAST_MS - SUCCESS_TOAST_MS)) + act(() => { + vi.advanceTimersByTime(ERROR_TOAST_MS - SUCCESS_TOAST_MS) + }) expect(useUiStore.getState().toast).toBeNull() } finally { vi.useRealTimers() @@ -137,11 +145,15 @@ describe("Toast", () => { show("Saved") await user.hover(screen.getByRole("status")) - act(() => vi.advanceTimersByTime(SUCCESS_TOAST_MS * 3)) + act(() => { + vi.advanceTimersByTime(SUCCESS_TOAST_MS * 3) + }) expect(screen.getByRole("status")).toHaveTextContent("Saved") await user.unhover(screen.getByRole("status")) - act(() => vi.advanceTimersByTime(SUCCESS_TOAST_MS)) + act(() => { + vi.advanceTimersByTime(SUCCESS_TOAST_MS) + }) expect(useUiStore.getState().toast).toBeNull() }) }) diff --git a/apps/admin/src/features/analytics/analytics-charts.tsx b/apps/admin/src/features/analytics/analytics-charts.tsx index 710a8e7..4dd0e7a 100644 --- a/apps/admin/src/features/analytics/analytics-charts.tsx +++ b/apps/admin/src/features/analytics/analytics-charts.tsx @@ -2,7 +2,8 @@ import type { CSSProperties } from "react" -import { barHeightPcts, barValueLabel, sparkHeightPcts } from "@/features/analytics/chart-geometry" +import { barHeightPcts, sparkHeightPcts } from "@/features/analytics/chart-geometry" +import { formatCompactCount } from "@/lib/display" // Class names and DOM match the `.barchart*` and `.hub-spark*` rules in admin.css. @@ -14,14 +15,13 @@ export function BarChart({ values, labels }: { values: number[]; labels?: string return (
{values.map((v, i) => ( - // eslint-disable-next-line react/no-array-index-key
- {barValueLabel(v)} + {formatCompactCount(v)}
{labels &&
{labels[i]}
} @@ -45,7 +45,6 @@ export function Spark({
{heights.map((height, i) => ( { renderPage() const c = await findCard("Pins per week") - expect(await within(c).findByText("1.2k")).toBeInTheDocument() + expect(await within(c).findByText("1.2K")).toBeInTheDocument() expect(within(c).getByText("3")).toBeInTheDocument() expect(within(c).getByText("Wk A")).toBeInTheDocument() expect(within(c).getByText("Wk B")).toBeInTheDocument() diff --git a/apps/admin/src/features/analytics/analytics-rank-cards.tsx b/apps/admin/src/features/analytics/analytics-rank-cards.tsx index 9c4ee09..2764499 100644 --- a/apps/admin/src/features/analytics/analytics-rank-cards.tsx +++ b/apps/admin/src/features/analytics/analytics-rank-cards.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react" -import { INITIALS_MAX_LETTERS } from "@/lib/display" +import { initials } from "@/lib/display" import { EMPTY_VALUE } from "@/lib/empty-value" import { AnalyticsCard } from "@/features/analytics/analytics-card" import type { @@ -12,16 +12,6 @@ import type { const GOOD_VALUE_STYLE = { color: "var(--moss-700)", fontWeight: 700 } as const -function initials(name: string): string { - const letters = name - .split(" ") - .map((word) => word[0] ?? "") - .slice(0, INITIALS_MAX_LETTERS) - .join("") - .toUpperCase() - return letters || "?" -} - interface RankRow { key: string lead: ReactNode diff --git a/apps/admin/src/features/analytics/chart-geometry.test.ts b/apps/admin/src/features/analytics/chart-geometry.test.ts index 8d219db..0deb6b6 100644 --- a/apps/admin/src/features/analytics/chart-geometry.test.ts +++ b/apps/admin/src/features/analytics/chart-geometry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { barHeightPcts, barValueLabel, sparkHeightPcts } from "./chart-geometry" +import { barHeightPcts, sparkHeightPcts } from "./chart-geometry" describe("bar chart geometry", () => { it("scales every bar against the tallest one", () => { @@ -14,12 +14,6 @@ describe("bar chart geometry", () => { it("returns no heights for an empty series", () => { expect(barHeightPcts([])).toEqual([]) }) - - it("abbreviates values from one thousand up", () => { - expect(barValueLabel(999)).toBe("999") - expect(barValueLabel(1000)).toBe("1.0k") - expect(barValueLabel(1540)).toBe("1.5k") - }) }) describe("sparkline geometry", () => { diff --git a/apps/admin/src/features/analytics/chart-geometry.ts b/apps/admin/src/features/analytics/chart-geometry.ts index e9e533f..2e03769 100644 --- a/apps/admin/src/features/analytics/chart-geometry.ts +++ b/apps/admin/src/features/analytics/chart-geometry.ts @@ -2,8 +2,6 @@ const SPARK_FLOOR_PCT = 10 const SPARK_SPAN_PCT = 88 -const COMPACT_BAR_VALUE_FROM = 1000 - export function barHeightPcts(values: readonly number[]): number[] { const max = Math.max(...values, 1) return values.map((v) => (v / max) * 100) @@ -14,9 +12,3 @@ export function sparkHeightPcts(values: readonly number[]): number[] { const min = Math.min(...values) return values.map((v) => SPARK_FLOOR_PCT + ((v - min) / (max - min || 1)) * SPARK_SPAN_PCT) } - -export function barValueLabel(value: number): string { - return value >= COMPACT_BAR_VALUE_FROM - ? `${(value / COMPACT_BAR_VALUE_FROM).toFixed(1)}k` - : String(value) -} diff --git a/apps/admin/src/features/discovery/discovery-page.test.tsx b/apps/admin/src/features/discovery/discovery-page.test.tsx index 1006ae7..f94d941 100644 --- a/apps/admin/src/features/discovery/discovery-page.test.tsx +++ b/apps/admin/src/features/discovery/discovery-page.test.tsx @@ -172,14 +172,14 @@ describe("DiscoveryPage", () => { expect(laRow).toHaveTextContent("City") expect(laRow).toHaveTextContent("Needs contact") expect(laRow).toHaveTextContent("0644000") - expect(laRow).toHaveTextContent("3899k pop") + expect(laRow).toHaveTextContent("3.9M pop") expect(laRow).toHaveTextContent("4 waiting") expect(laRow).toHaveTextContent("City limits") expect(laRow).toHaveTextContent("3d") const pasadenaRow = screen.getByRole("button", { name: /^Pasadena/ }) expect(pasadenaRow).toHaveTextContent("Routed") - expect(pasadenaRow).toHaveTextContent("139k pop") + expect(pasadenaRow).toHaveTextContent("138.7K pop") expect(chip(/^Needs mapping/)).toHaveTextContent("2") expect(chip(/^Routed/)).toHaveTextContent("12") diff --git a/apps/admin/src/features/discovery/discovery-page.tsx b/apps/admin/src/features/discovery/discovery-page.tsx index 011b3d6..f2afe97 100644 --- a/apps/admin/src/features/discovery/discovery-page.tsx +++ b/apps/admin/src/features/discovery/discovery-page.tsx @@ -5,14 +5,15 @@ import type { JurisdictionDirectoryDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" -import { LoadingState, ErrorState } from "@/components/shared/data-states" -import { useJurisdictionDirectory } from "@/features/discovery/use-discovery" +import { ListCard, ListStates, LoadMoreButton, SearchBox } from "@/components/shared/section-list" +import { useNow } from "@/hooks/use-now" +import { useSelection } from "@/hooks/use-selection" +import { useJurisdictionListInfinite } from "@/features/discovery/use-discovery" import { directoryCounts, - getCountDisplay, - getJurisdictionSort, + countDisplay, + effectiveJurisdictionSort, initialDirectoryState, - pickSelected, toDirectoryQuery, type CountDisplay, type JurisdictionFilter, @@ -27,9 +28,10 @@ import { } from "@/features/discovery/jurisdiction-view" import { UnmappedDetail } from "@/features/discovery/unmapped-detail" import { SEARCH_DEBOUNCE_MS } from "@/lib/timing" +import { flatPages } from "@/lib/infinite" import type { SectionPageProps } from "@/components/shell/page-registry" -type DirectoryList = ReturnType +type DirectoryList = ReturnType function useDirectoryFilters(focusId: string | null) { const [initial] = React.useState(() => initialDirectoryState(focusId)) @@ -54,50 +56,7 @@ function useDirectoryFilters(focusId: string | null) { return { filter, setFilter, layer, setLayer, sort, setSort, query, setQuery, debouncedQuery } } -// 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]) - - const selected = pickSelected(items, selectedId, lastSeen) - React.useEffect(() => { - if (selected && selected !== lastSeen) setLastSeen(selected) - }, [selected, lastSeen]) - - 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 - } - 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]) - - const notListed = - selectedId !== null && selected === null && !listQuery.isLoading && !listQuery.isError - return { selectedId, setSelectedId, selected, notListed } -} +const geoidOf = (item: JurisdictionDirectoryDTO) => item.geoid function DirectoryToolbar({ filters, @@ -119,16 +78,12 @@ function DirectoryToolbar({ onChange={(v) => setFilter(v as JurisdictionFilter)} />
-
- - setQuery(e.target.value)} - /> -
+
Type setSort(e.target.value as JurisdictionSort)} aria-label="Sort jurisdictions" @@ -174,42 +129,32 @@ function DirectoryListBody({ onSelect: (geoid: string) => void showOldest: boolean }) { - if (listQuery.isLoading) return - if (listQuery.isError) { - return listQuery.refetch()} /> - } - if (items.length === 0) { - return ( - } - /> - ) - } + const now = useNow() return ( - <> + } + /> + } + > {items.map((item) => ( onSelect(item.geoid)} + onSelect={onSelect} + now={now} showOldest={showOldest} /> ))} - {listQuery.hasNextPage && ( - - )} - + + ) } @@ -251,13 +196,13 @@ export function DiscoveryPage({ focusId }: SectionPageProps) { const filters = useDirectoryFilters(focusId) const { filter, layer, sort, debouncedQuery } = filters const listParams = toDirectoryQuery(filter, sort, layer, debouncedQuery) - const listQuery = useJurisdictionDirectory(listParams) - const needsMappingQuery = useJurisdictionDirectory( + const listQuery = useJurisdictionListInfinite(listParams) + const needsMappingQuery = useJurisdictionListInfinite( toDirectoryQuery("attention", sort, layer, debouncedQuery), ) const items = React.useMemo( - () => listQuery.data?.pages.flatMap((p) => p.items) ?? [], + () => flatPages(listQuery.data), [listQuery.data], ) const firstPage = listQuery.data?.pages[0] @@ -265,7 +210,7 @@ export function DiscoveryPage({ focusId }: SectionPageProps) { filter, total: firstPage?.total ?? null, facets: firstPage?.facets ?? null, - needsMapping: getCountDisplay({ + needsMapping: countDisplay({ count: needsMappingQuery.data?.pages[0]?.total ?? null, isLoading: needsMappingQuery.isLoading, isError: needsMappingQuery.isError, @@ -274,12 +219,17 @@ export function DiscoveryPage({ focusId }: SectionPageProps) { loadedCount: items.length, }) - const selection = useJurisdictionSelection({ + // There is no by-id read here, so a pick that a filter or search leaves out stays open as last listed. + const { selectedId, setSelectedId, selectedItem: selected } = useSelection({ focusId, + list: listQuery, items, - listQuery, + getId: geoidOf, listKey: JSON.stringify([listParams.filter, listParams.sort, layer, debouncedQuery]), + keepLastSeen: true, }) + const notListed = + selectedId !== null && selected === null && !listQuery.isLoading && !listQuery.isError return ( <> @@ -296,32 +246,26 @@ export function DiscoveryPage({ focusId }: SectionPageProps) {
-
-
-

+ {typeof counts.header === "number" ? counts.header.toLocaleString() : counts.header}{" "} {counts.header === 1 ? "jurisdiction" : "jurisdictions"} -

-
- click a row → -
-
- -
-
+ + } + meta="click a row →" + > + +
- +
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 96943dd..f87fea4 100644 --- a/apps/admin/src/features/discovery/discovery-ui-state.test.ts +++ b/apps/admin/src/features/discovery/discovery-ui-state.test.ts @@ -2,23 +2,23 @@ import { describe, expect, it } from "vitest" import { EMPTY_VALUE } from "@/lib/empty-value" import { - getCountDisplay, - getJurisdictionSort, + countDisplay, + effectiveJurisdictionSort, initialDirectoryState, pickSelected, } from "./discovery-ui-state" describe("Jurisdictions UI state", () => { it("always uses oldest sort for the Needs mapping filter", () => { - expect(getJurisdictionSort("attention", "pop")).toBe("oldest") - expect(getJurisdictionSort("attention", "reports")).toBe("oldest") - expect(getJurisdictionSort("attention", "oldest")).toBe("oldest") + expect(effectiveJurisdictionSort("attention", "pop")).toBe("oldest") + expect(effectiveJurisdictionSort("attention", "reports")).toBe("oldest") + expect(effectiveJurisdictionSort("attention", "oldest")).toBe("oldest") }) it("shows loading and unavailable count labels instead of false zeroes", () => { - expect(getCountDisplay({ count: null, isLoading: true, isError: false })).toBe("Loading…") - expect(getCountDisplay({ count: null, isLoading: false, isError: true })).toBe(EMPTY_VALUE) - expect(getCountDisplay({ count: 0, isLoading: false, isError: false })).toBe(0) + expect(countDisplay({ count: null, isLoading: true, isError: false })).toBe("Loading…") + expect(countDisplay({ count: null, isLoading: false, isError: true })).toBe(EMPTY_VALUE) + expect(countDisplay({ count: 0, isLoading: false, isError: false })).toBe(0) }) }) diff --git a/apps/admin/src/features/discovery/discovery-ui-state.ts b/apps/admin/src/features/discovery/discovery-ui-state.ts index 6e98b72..45e9103 100644 --- a/apps/admin/src/features/discovery/discovery-ui-state.ts +++ b/apps/admin/src/features/discovery/discovery-ui-state.ts @@ -1,18 +1,19 @@ import type { JurisdictionLayer, JurisdictionListQuery } from "@civfix/shared" +import { resolveSelected } from "@/hooks/use-selection" import { EMPTY_VALUE } from "@/lib/empty-value" export type JurisdictionFilter = "all" | "attention" | "clear" export type JurisdictionSort = "pop" | "reports" | "oldest" -export function getJurisdictionSort( +export function effectiveJurisdictionSort( filter: JurisdictionFilter, selectedSort: JurisdictionSort, ): JurisdictionSort { return filter === "attention" ? "oldest" : selectedSort } -export function getCountDisplay({ +export function countDisplay({ count, isLoading, isError, @@ -40,8 +41,7 @@ export function pickSelected( selId: string | null, lastSeen: T | null, ): T | null { - if (selId === null) return null - return items.find((x) => x.geoid === selId) ?? (lastSeen?.geoid === selId ? lastSeen : null) + return resolveSelected(items, selId, lastSeen, (item) => item.geoid) } export type DirectoryQuery = Pick @@ -66,13 +66,13 @@ export function toDirectoryQuery( ): DirectoryQuery { return { filter: SERVER_FILTER[filter], - sort: SERVER_SORT[getJurisdictionSort(filter, sort)], + sort: SERVER_SORT[effectiveJurisdictionSort(filter, sort)], ...(layer !== "all" ? { layer } : {}), ...(q ? { q } : {}), } } -export type CountDisplay = ReturnType +export type CountDisplay = ReturnType export function directoryCounts({ filter, @@ -89,7 +89,7 @@ export function directoryCounts({ list: { isLoading: boolean; isError: boolean } loadedCount: number }): { chips: Record; header: CountDisplay } { - const listCount = (count: number | null) => getCountDisplay({ count, ...list }) + const listCount = (count: number | null) => countDisplay({ count, ...list }) const allTotal = facets ? facets.routed + facets.unrouted : filter === "all" ? total : null const listedTotal = filter === "clear" ? facets?.routed : total return { diff --git a/apps/admin/src/features/discovery/jurisdiction-detail-panels.tsx b/apps/admin/src/features/discovery/jurisdiction-detail-panels.tsx index 48276a7..fd82cd9 100644 --- a/apps/admin/src/features/discovery/jurisdiction-detail-panels.tsx +++ b/apps/admin/src/features/discovery/jurisdiction-detail-panels.tsx @@ -26,7 +26,6 @@ export function JurisdictionHeader({ dto }: { dto: JurisdictionDirectoryDTO }) {
{headPin ? ( - // eslint-disable-next-line @next/next/no-img-element ) : ( @@ -221,7 +220,6 @@ export function CategoryContactsGrid({ >
- {/* eslint-disable-next-line @next/next/no-img-element */} {c.label} diff --git a/apps/admin/src/features/discovery/jurisdiction-row.test.tsx b/apps/admin/src/features/discovery/jurisdiction-row.test.tsx new file mode 100644 index 0000000..075f35a --- /dev/null +++ b/apps/admin/src/features/discovery/jurisdiction-row.test.tsx @@ -0,0 +1,47 @@ +import { render } from "@testing-library/react" +import type { JurisdictionDirectoryDTO } from "@civfix/shared" +import { describe, expect, it } from "vitest" + +import { MINUTE_MS } from "@/lib/timing" +import { JurisdictionRow } from "@/features/discovery/jurisdiction-row" + +const HOUR_MS = 60 * MINUTE_MS +const NOW = Date.parse("2026-09-20T15:00:00.000Z") + +const ITEM = { + geoid: "0644000", + org: "Los Angeles", + dept: null, + email: null, + form: null, + method: "none", + status: "pending", + coverage: "City limits", + lastRouted: null, + layer: "place", + population: 3_898_747, + reportsWaiting: 4, + perCategoryCounts: { trash: 3, graffiti: 1 }, + contacts: [], + flaggedAt: null, + handle: null, + oldestReportAt: new Date(NOW - 23 * HOUR_MS).toISOString(), + forwardSubjectTemplate: null, + forwardBodyTemplate: null, +} satisfies JurisdictionDirectoryDTO + +describe("JurisdictionRow waiting age", () => { + it("reads its age and overdue state from the now prop", () => { + const row = (now: number) => ( + undefined} now={now} showOldest /> + ) + const { container, rerender } = render(row(NOW)) + const age = () => container.querySelector(".juris-age") + expect(age()).toHaveTextContent("23h") + expect(age()).not.toHaveClass("overdue") + + rerender(row(NOW + 2 * HOUR_MS)) + expect(age()).toHaveTextContent("1d") + expect(age()).toHaveClass("overdue") + }) +}) diff --git a/apps/admin/src/features/discovery/jurisdiction-row.tsx b/apps/admin/src/features/discovery/jurisdiction-row.tsx index 03c92e0..168f126 100644 --- a/apps/admin/src/features/discovery/jurisdiction-row.tsx +++ b/apps/admin/src/features/discovery/jurisdiction-row.tsx @@ -1,6 +1,6 @@ "use client" -import type * as React from "react" +import * as React from "react" import type { JurisdictionDirectoryDTO } from "@civfix/shared" import { Icons } from "@/components/icons" @@ -9,19 +9,18 @@ import { LAYER_LABEL, UNMAPPED_GEOID, dominantCategory, - formatPopulation, - formatRoutedDate, formatWaitingAge, isOverdue, } from "@/features/discovery/jurisdiction-view" import { RoutingStatusPill } from "@/features/discovery/routing-status-pill" import { categoryPinSrc } from "@/lib/category" +import { formatMonthDay } from "@/lib/dates" +import { formatCompactCount } from "@/lib/display" function MappedLeading({ item }: { item: JurisdictionDirectoryDTO }) { const dominant = dominantCategory(item.perCategoryCounts) const pin = dominant ? categoryPinSrc(dominant) : null if (!pin) return - // eslint-disable-next-line @next/next/no-img-element return } @@ -43,7 +42,7 @@ function MappedBadges({ item }: { item: JurisdictionDirectoryDTO }) { function MappedSub({ item }: { item: JurisdictionDirectoryDTO }) { return ( <> - {formatPopulation(item.population)} pop + {formatCompactCount(item.population)} pop · {item.reportsWaiting} waiting · @@ -52,15 +51,15 @@ function MappedSub({ item }: { item: JurisdictionDirectoryDTO }) { ) } -function WaitingAge({ oldestReportAt }: { oldestReportAt: string | null }) { - const overdue = isOverdue(oldestReportAt) +function WaitingAge({ oldestReportAt, now }: { oldestReportAt: string | null; now: number }) { + const overdue = isOverdue(oldestReportAt, now) return ( {overdue && } - {formatWaitingAge(oldestReportAt)} + {formatWaitingAge(oldestReportAt, now)} ) } @@ -69,35 +68,39 @@ function RowAge({ item, unmapped, showOldest, + now, }: { item: JurisdictionDirectoryDTO unmapped: boolean showOldest: boolean + now: number }) { if (showOldest) { if (unmapped && !item.oldestReportAt) return null - return + return } if (unmapped) return null - return {formatRoutedDate(item.lastRouted)} + return {formatMonthDay(item.lastRouted)} } -export function JurisdictionRow({ +export const JurisdictionRow = React.memo(function JurisdictionRow({ item, selected, - onClick, + onSelect, + now, showOldest = false, }: { item: JurisdictionDirectoryDTO selected: boolean - onClick: () => void + onSelect: (id: string) => void + now: number showOldest?: boolean }) { const unmapped = item.geoid === UNMAPPED_GEOID const onKeyDown = (event: React.KeyboardEvent) => { if (!isKeyboardActivationKey(event.key)) return event.preventDefault() - onClick() + onSelect(item.geoid) } return ( @@ -106,7 +109,7 @@ export function JurisdictionRow({ role="button" tabIndex={0} aria-current={selected ? "true" : undefined} - onClick={onClick} + onClick={() => onSelect(item.geoid)} onKeyDown={onKeyDown} >
@@ -137,11 +140,11 @@ export function JurisdictionRow({
- +
) -} +}) diff --git a/apps/admin/src/features/discovery/jurisdiction-view.test.ts b/apps/admin/src/features/discovery/jurisdiction-view.test.ts index 4e55d47..900ca40 100644 --- a/apps/admin/src/features/discovery/jurisdiction-view.test.ts +++ b/apps/admin/src/features/discovery/jurisdiction-view.test.ts @@ -5,7 +5,6 @@ import { EMPTY_VALUE } from "@/lib/empty-value" import { LAYER_LABEL, dominantCategory, - formatPopulation, formatWaitingAge, isOverdue, } from "./jurisdiction-view" @@ -42,12 +41,6 @@ describe("jurisdiction row formatting", () => { expect(isOverdue(null, NOW)).toBe(false) }) - it("compacts populations", () => { - expect(formatPopulation(950)).toBe("950") - expect(formatPopulation(1_250)).toBe("1.3k") - expect(formatPopulation(138_699)).toBe("139k") - }) - it("picks the category with the most waiting reports", () => { expect(dominantCategory({ trash: 1, graffiti: 3 })).toBe("graffiti") expect(dominantCategory({})).toBeNull() diff --git a/apps/admin/src/features/discovery/jurisdiction-view.ts b/apps/admin/src/features/discovery/jurisdiction-view.ts index 526976e..42461cd 100644 --- a/apps/admin/src/features/discovery/jurisdiction-view.ts +++ b/apps/admin/src/features/discovery/jurisdiction-view.ts @@ -1,8 +1,9 @@ -import type { - JurisdictionDirectoryDTO, - JurisdictionLayer, - PerCategoryCounts, - ReportCategory, +import { + relativeAgo, + type JurisdictionDirectoryDTO, + type JurisdictionLayer, + type PerCategoryCounts, + type ReportCategory, } from "@civfix/shared" import { REPORT_CATEGORIES, categoryLabel, categoryPinSrc, categoryReportTypes } from "@/lib/category" @@ -43,9 +44,6 @@ const SHOW_WEEKS_FROM_DAYS = 14 const SHOW_MONTHS_FROM_WEEKS = 8 const OVERDUE_AFTER_MS = DAY_MS -const THOUSAND = 1000 -const WHOLE_THOUSANDS_FROM = 10_000 - export function routingCount(counts: PerCategoryCounts, id: ReportCategory): number { return counts[id] ?? 0 } @@ -67,24 +65,9 @@ export function needsAttention(dto: JurisdictionDirectoryDTO): boolean { return dto.reportsWaiting > 0 && dto.method === "none" } -export function formatRoutedDate(iso: string | null): string { - if (!iso) return EMPTY_VALUE - const date = new Date(iso) - if (Number.isNaN(date.getTime())) return EMPTY_VALUE - return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }) -} - -export function formatWaitingAge(iso: string | null, now = Date.now()): string { - if (!iso) return EMPTY_VALUE - const then = new Date(iso).getTime() - if (Number.isNaN(then)) return EMPTY_VALUE - const elapsed = now - then - if (elapsed < MINUTE_MS) return "just now" - const minutes = Math.floor(elapsed / MINUTE_MS) - if (minutes < 60) return `${minutes}m` - const hours = Math.floor(elapsed / HOUR_MS) - if (hours < 24) return `${hours}h` - const days = Math.floor(elapsed / DAY_MS) +// Reports can wait months, so past a week this keeps counting in days, then weeks, months and years +// where the shared compact age would switch to an ever-growing week count. +function longWaitingAge(days: number): string { if (days < SHOW_WEEKS_FROM_DAYS) return `${days}d` const weeks = Math.floor(days / DAYS_PER_WEEK) if (weeks < SHOW_MONTHS_FROM_WEEKS) return `${weeks}w` @@ -93,16 +76,17 @@ export function formatWaitingAge(iso: string | null, now = Date.now()): string { return `${Math.floor(days / DAYS_PER_YEAR)}y` } +export function formatWaitingAge(iso: string | null, now = Date.now()): string { + if (!iso || Number.isNaN(Date.parse(iso))) return EMPTY_VALUE + return relativeAgo(iso, now, { + justNow: "just now", + absoluteFallback: (date) => longWaitingAge(Math.floor((now - date.getTime()) / DAY_MS)), + }) +} + export function isOverdue(iso: string | null, now = Date.now()): boolean { if (!iso) return false const then = new Date(iso).getTime() if (Number.isNaN(then)) return false return now - then > OVERDUE_AFTER_MS } - -export function formatPopulation(population: number): string { - if (population >= THOUSAND) { - return (population / THOUSAND).toFixed(population >= WHOLE_THOUSANDS_FROM ? 0 : 1) + "k" - } - return String(population) -} diff --git a/apps/admin/src/features/discovery/unmapped-detail.tsx b/apps/admin/src/features/discovery/unmapped-detail.tsx index 835e1c0..f565a6e 100644 --- a/apps/admin/src/features/discovery/unmapped-detail.tsx +++ b/apps/admin/src/features/discovery/unmapped-detail.tsx @@ -48,7 +48,6 @@ export function UnmappedDetail({ dto }: { dto: JurisdictionDirectoryDTO }) {
- {/* eslint-disable-next-line @next/next/no-img-element */} {c.label} diff --git a/apps/admin/src/features/discovery/use-discovery.ts b/apps/admin/src/features/discovery/use-discovery.ts index fabd921..6eadf06 100644 --- a/apps/admin/src/features/discovery/use-discovery.ts +++ b/apps/admin/src/features/discovery/use-discovery.ts @@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient, + type QueryClient, } from "@tanstack/react-query" import type { AddNoteRequest, @@ -13,7 +14,6 @@ import type { DiscoveryListResponse, FlagDiscoveryRequest, GetDiscoveryTaskResponse, - JurisdictionDirectoryResponse, JurisdictionGeometryResponse, PatchJurisdictionRequest, SaveContactsRequest, @@ -22,7 +22,8 @@ import type { import { api } from "@/lib/api" import { errorMessage } from "@/lib/error-messages" -import { queryKeys } from "@/lib/query" +import { infiniteListOptions } from "@/lib/infinite" +import { invalidateKeys, queryKeys } from "@/lib/query" import { MINUTE_MS } from "@/lib/timing" import { partialSaveMessage, type SavedExtras } from "@/features/discovery/discovery-payloads" import type { DirectoryQuery } from "@/features/discovery/discovery-ui-state" @@ -34,14 +35,6 @@ export function useDiscoveryList(params: DiscoveryListQuery) { }) } -export function useDiscoveryTask(id: string | null) { - return useQuery({ - queryKey: queryKeys.discovery.detail(id ?? ""), - queryFn: () => api.getDiscoveryTask({ id: id as string }), - enabled: !!id, - }) -} - /** The wire caps a page at 100; 50 keeps each scroll step quick. */ const DIRECTORY_PAGE_SIZE = 50 @@ -51,18 +44,14 @@ const GEOMETRY_STALE_MS = 5 * MINUTE_MS * 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: DirectoryQuery) { - return useInfiniteQuery({ - queryKey: queryKeys.jurisdictions.list(params), - queryFn: ({ pageParam }) => - api.listJurisdictions({ - ...params, - limit: DIRECTORY_PAGE_SIZE, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) +export function useJurisdictionListInfinite(params: DirectoryQuery) { + return useInfiniteQuery( + infiniteListOptions( + queryKeys.jurisdictions.list(params), + { ...params, limit: DIRECTORY_PAGE_SIZE }, + (input) => api.listJurisdictions(input), + ), + ) } /** @@ -82,28 +71,36 @@ export function useJurisdictionGeometry(geoid: string | null) { const BOUNDARY_KEY = queryKeys.jurisdictions.geometry("").slice(0, -1) /** The home aggregates count saved contacts and flags, so every discovery write refreshes them too. */ -function invalidateDiscovery(qc: ReturnType) { - return Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.discovery.all }), +function invalidateDiscovery(qc: QueryClient, id?: string) { + return invalidateKeys(qc, [ + id ? queryKeys.discovery.detail(id) : null, + queryKeys.discovery.all, // No discovery write changes a boundary, and refetching the polygon would hold every save's // pending state on the largest payload in the section. - qc.invalidateQueries({ + { queryKey: queryKeys.jurisdictions.all, predicate: (query) => !partialMatchKey(query.queryKey, BOUNDARY_KEY), - }), - qc.invalidateQueries({ queryKey: queryKeys.home.all }), + }, + queryKeys.home.all, ]) } +// Unused until the discovery notes decision (append-only task notes vs the jurisdiction note field): +// it reads the discovery task that owns the note history. +/** @public */ +export function useDiscoveryTask(id: string | null) { + return useQuery({ + queryKey: queryKeys.discovery.detail(id ?? ""), + queryFn: () => api.getDiscoveryTask({ id: id as string }), + enabled: !!id, + }) +} + export function useAddDiscoveryNote() { const qc = useQueryClient() return useMutation({ mutationFn: (input: AddNoteRequest) => api.addDiscoveryNote(input), - onSuccess: (_res, { id }) => - Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.discovery.detail(id) }), - invalidateDiscovery(qc), - ]), + onSuccess: (_res, { id }) => invalidateDiscovery(qc, id), }) } @@ -111,11 +108,7 @@ export function useFlagDiscovery() { const qc = useQueryClient() return useMutation({ mutationFn: (input: FlagDiscoveryRequest) => api.flagDiscovery(input), - onSuccess: (_res, { id }) => - Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.discovery.detail(id) }), - invalidateDiscovery(qc), - ]), + onSuccess: (_res, { id }) => invalidateDiscovery(qc, id), }) } @@ -123,11 +116,7 @@ export function useSaveDiscoveryDraft() { const qc = useQueryClient() return useMutation({ mutationFn: (input: SaveDraftRequest) => api.saveDiscoveryDraft(input), - onSuccess: (_res, { id }) => - Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.discovery.detail(id) }), - invalidateDiscovery(qc), - ]), + onSuccess: (_res, { id }) => invalidateDiscovery(qc, id), }) } diff --git a/apps/admin/src/features/events/event-attendee-messages.tsx b/apps/admin/src/features/events/event-attendee-messages.tsx index 86554d3..0860a9d 100644 --- a/apps/admin/src/features/events/event-attendee-messages.tsx +++ b/apps/admin/src/features/events/event-attendee-messages.tsx @@ -16,7 +16,6 @@ function SentMessages({ messages }: { messages: AdminEventDTO["messages"] }) {
{messages.map((m, i) => (
diff --git a/apps/admin/src/features/events/event-detail.tsx b/apps/admin/src/features/events/event-detail.tsx index 03711ae..c7016f4 100644 --- a/apps/admin/src/features/events/event-detail.tsx +++ b/apps/admin/src/features/events/event-detail.tsx @@ -8,10 +8,10 @@ import { Icons, type IconComponent } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" import { confirmDialog } from "@/components/shared/dialog" -import { cancelBlockedFor, eventStatusView } from "@/lib/event-status" +import { shortRef } from "@/lib/display" +import { cancelBlockedMessage, eventStatusView } from "@/lib/event-status" import { eventKindView, EVENT_KIND_PIN_KIND } from "@/lib/event-kind" import { EventAttendeeMessages, useAttendeeUpdate } from "@/features/events/event-attendee-messages" -import { shortId } from "@/features/events/event-id" import { EventLinkedReports } from "@/features/events/event-linked-reports" import { EventOrganizerCard } from "@/features/events/event-organizer-card" import { EventTurnoutCard, useOutcomeDraft } from "@/features/events/event-turnout-card" @@ -57,7 +57,7 @@ function EventHead({ event }: { event: AdminEventDTO }) {
- {shortId(event.id)} · {eventKindView(event.eventKind).label} + {shortRef(event.id)} · {eventKindView(event.eventKind).label}

{event.title}

@@ -157,7 +157,6 @@ function EventActivity({ timeline }: { timeline: EventTimelineItem[] }) { ) : (
{timeline.map((t, i) => ( - // eslint-disable-next-line react/no-array-index-key ))}
@@ -182,7 +181,7 @@ function EventModerateBar({ }) { const { flagMutation, cancelMutation } = moderation const cancelBlockedId = React.useId() - const cancelBlockedReason = cancelBlockedFor(event.status) + const cancelBlockedReason = cancelBlockedMessage(event.status) const onCancel = async () => { const ok = await confirmDialog({ diff --git a/apps/admin/src/features/events/event-id.ts b/apps/admin/src/features/events/event-id.ts deleted file mode 100644 index b63d725..0000000 --- a/apps/admin/src/features/events/event-id.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SHORT_ID_LENGTH } from "@/lib/display" - -const ID_DASHES = /-/g - -export function shortId(id: string): string { - return `#${id.replace(ID_DASHES, "").slice(0, SHORT_ID_LENGTH)}` -} diff --git a/apps/admin/src/features/events/event-linked-reports.tsx b/apps/admin/src/features/events/event-linked-reports.tsx index a404151..b745103 100644 --- a/apps/admin/src/features/events/event-linked-reports.tsx +++ b/apps/admin/src/features/events/event-linked-reports.tsx @@ -25,7 +25,6 @@ function LinkedReportCard({ const card = ( - )} - - ) -} - export function EventListPane({ listQuery, items, @@ -66,22 +15,31 @@ export function EventListPane({ selId, onSelect, }: { - listQuery: EventListQuery + listQuery: ReturnType items: AdminEventListItemDTO[] listCount: number selId: string | null onSelect: (id: string) => void }) { return ( -
-
-

Events

-
- {listCount} -
-
- -
-
+ + } + /> + } + > + {items.map((e) => ( + + ))} + + + ) } diff --git a/apps/admin/src/features/events/event-organizer-card.tsx b/apps/admin/src/features/events/event-organizer-card.tsx index e7f8087..a119c2c 100644 --- a/apps/admin/src/features/events/event-organizer-card.tsx +++ b/apps/admin/src/features/events/event-organizer-card.tsx @@ -2,7 +2,7 @@ import type { AdminEventDTO } from "@civfix/shared" -import { initials } from "@/features/reports/person-name" +import { initials } from "@/lib/display" import { useNav } from "@/store/ui-store" // The server sends "-" when the organizer's join date is unknown. diff --git a/apps/admin/src/features/events/event-row.tsx b/apps/admin/src/features/events/event-row.tsx index 7dbc673..b7b3e0d 100644 --- a/apps/admin/src/features/events/event-row.tsx +++ b/apps/admin/src/features/events/event-row.tsx @@ -1,13 +1,13 @@ "use client" +import * as React from "react" import type { AdminEventListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" import { eventStatusView } from "@/lib/event-status" import { eventKindView } from "@/lib/event-kind" -import { shortId } from "@/features/events/event-id" -import { firstName } from "@/features/reports/person-name" +import { firstName, shortRef } from "@/lib/display" import { useNav } from "@/store/ui-store" function OrganizerLink({ organizer }: { organizer: AdminEventListItemDTO["organizer"] }) { @@ -27,14 +27,14 @@ function OrganizerLink({ organizer }: { organizer: AdminEventListItemDTO["organi ) } -export function EventRow({ +export const EventRow = React.memo(function EventRow({ item, selected, - onClick, + onSelect, }: { item: AdminEventListItemDTO selected: boolean - onClick: () => void + onSelect: (id: string) => void }) { const kindView = eventKindView(item.eventKind) const KindIcon = kindView.icon @@ -45,12 +45,12 @@ export function EventRow({ role="button" tabIndex={0} aria-current={selected ? "true" : undefined} - onClick={onClick} + onClick={() => onSelect(item.id)} onKeyDown={(e) => { // A key pressed on the nested organizer link bubbles here; it must stay that link's activation. if (e.target !== e.currentTarget || !isKeyboardActivationKey(e.key)) return e.preventDefault() - onClick() + onSelect(item.id) }} >
@@ -72,7 +72,7 @@ export function EventRow({ )} - {shortId(item.id)} + {shortRef(item.id)}
@@ -89,4 +89,4 @@ export function EventRow({
) -} +}) diff --git a/apps/admin/src/features/events/events-page.tsx b/apps/admin/src/features/events/events-page.tsx index 536ace5..9d59e43 100644 --- a/apps/admin/src/features/events/events-page.tsx +++ b/apps/admin/src/features/events/events-page.tsx @@ -5,8 +5,11 @@ import type { AdminEventCounts } from "@civfix/shared" import { Icons } from "@/components/icons" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" +import { SearchBox } from "@/components/shared/section-list" import { useDebounced } from "@/hooks/use-debounced" +import { idOf, useSelection } from "@/hooks/use-selection" import { EVENT_STATUS_VIEW } from "@/lib/event-status" +import { flatPages } from "@/lib/infinite" import { EventDetail } from "@/features/events/event-detail" import { EventListPane } from "@/features/events/event-list-pane" import { useEventListInfinite } from "@/features/events/use-events" @@ -39,7 +42,6 @@ function filterOptions(counts: AdminEventCounts) { export function EventsPage({ focusId }: SectionPageProps) { const [filter, setFilter] = React.useState("all") const [query, setQuery] = React.useState("") - const [selId, setSelId] = React.useState(focusId) const debouncedQuery = useDebounced(query) const listParams = { @@ -48,7 +50,7 @@ export function EventsPage({ focusId }: SectionPageProps) { } const listQuery = useEventListInfinite(listParams) const items = React.useMemo( - () => listQuery.data?.pages.flatMap((p) => p.items) ?? [], + () => flatPages(listQuery.data), [listQuery.data], ) @@ -56,26 +58,13 @@ export function EventsPage({ focusId }: SectionPageProps) { const counts = serverCounts ?? EMPTY_COUNTS const listCount = serverCounts ? serverCounts[filter] : items.length - // Only the first load picks an event on the operator's behalf, and a deep-linked event is never - // replaced. A pick that a filter or search leaves out stays open (the detail reads it by id); a pick - // that drops out of the same list after a refetch (a cancel under the Upcoming chip) clears, so the - // pane never jumps to another event's live buttons. Decided only on data fetched for the current params. - const listKey = JSON.stringify(listParams) - const [autoPick, setAutoPick] = React.useState(focusId === null) - const seenIn = React.useRef<{ id: string; list: string } | null>(null) - React.useEffect(() => { - if (focusId) setSelId(focusId) - }, [focusId]) - React.useEffect(() => { - if (!listQuery.isSuccess || listQuery.isFetching) return - if (selId === null) { - if (autoPick && items.length) setSelId(items[0]!.id) - return - } - setAutoPick(false) - if (items.some((x) => x.id === selId)) seenIn.current = { id: selId, list: listKey } - else if (seenIn.current?.id === selId && seenIn.current.list === listKey) setSelId(null) - }, [listQuery.isSuccess, listQuery.isFetching, items, selId, listKey, autoPick]) + const { selectedId: selId, setSelectedId: setSelId } = useSelection({ + focusId, + list: listQuery, + items, + getId: idOf, + listKey: JSON.stringify(listParams), + }) return ( <> @@ -96,16 +85,12 @@ export function EventsPage({ focusId }: SectionPageProps) { onChange={(v) => setFilter(v as EventFilter)} />
-
- - setQuery(e.target.value)} - /> -
+
diff --git a/apps/admin/src/features/events/link-reports-picker.tsx b/apps/admin/src/features/events/link-reports-picker.tsx index 5093fab..72f3f1b 100644 --- a/apps/admin/src/features/events/link-reports-picker.tsx +++ b/apps/admin/src/features/events/link-reports-picker.tsx @@ -10,6 +10,7 @@ import { usePristineDismiss } from "@/components/shared/backdrop-dismiss" import { useModalFocus } from "@/components/shared/modal-focus" import { useDebounced } from "@/hooks/use-debounced" import { reportStatusView } from "@/lib/report-status" +import { flatPages } from "@/lib/infinite" import { useReportListInfinite } from "@/features/reports/use-reports" type ReportListQuery = ReturnType @@ -122,7 +123,7 @@ export function LinkReportsPicker({ ) const candidates = React.useMemo( () => - (listQuery.data?.pages.flatMap((p) => p.items) ?? []).filter((r) => !excludeIds.has(r.id)), + flatPages(listQuery.data).filter((r) => !excludeIds.has(r.id)), [listQuery.data, excludeIds], ) diff --git a/apps/admin/src/features/events/use-events.ts b/apps/admin/src/features/events/use-events.ts index 1c9b812..58d72fc 100644 --- a/apps/admin/src/features/events/use-events.ts +++ b/apps/admin/src/features/events/use-events.ts @@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient, + type QueryClient, } from "@tanstack/react-query" import type { AdminEventListQuery, @@ -19,13 +20,14 @@ import type { } from "@civfix/shared" import { api } from "@/lib/api" -import { queryKeys } from "@/lib/query" +import { shortRef } from "@/lib/display" +import { infiniteListOptions } from "@/lib/infinite" +import { invalidateKeys, queryKeys } from "@/lib/query" import { useUiStore } from "@/store/ui-store" -import { shortId } from "@/features/events/event-id" import { pluralize } from "@/features/reports/plural" function eventMessage(id: string, text: string): string { - return `${shortId(id)} · ${text}` + return `${shortRef(id)} · ${text}` } export function useEventList(params: AdminEventListQuery) { @@ -36,16 +38,11 @@ export function useEventList(params: AdminEventListQuery) { } export function useEventListInfinite(params: AdminEventListQuery) { - return useInfiniteQuery({ - queryKey: queryKeys.events.list(params), - queryFn: ({ pageParam }) => - api.listAdminEvents({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) + return useInfiniteQuery( + infiniteListOptions(queryKeys.events.list(params), params, (input) => + api.listAdminEvents(input), + ), + ) } function eventQuery(id: string) { @@ -60,14 +57,14 @@ export function useEvent(id: string | null) { } // Profiles, org event tabs and signup pages all render an event's status, turnout and flag. -function invalidateEvents(qc: ReturnType, id: string) { - return Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.events.detail(id) }), - qc.invalidateQueries({ queryKey: queryKeys.events.all }), - qc.invalidateQueries({ queryKey: queryKeys.home.all }), - qc.invalidateQueries({ queryKey: queryKeys.users.all }), - qc.invalidateQueries({ queryKey: queryKeys.orgs.all }), - qc.invalidateQueries({ queryKey: queryKeys.pages.all }), +function invalidateEvents(qc: QueryClient, id: string) { + return invalidateKeys(qc, [ + queryKeys.events.detail(id), + queryKeys.events.all, + queryKeys.home.all, + queryKeys.users.all, + queryKeys.orgs.all, + queryKeys.pages.all, ]) } @@ -148,10 +145,9 @@ export function useUnlinkReport() { const qc = useQueryClient() return useMutation({ mutationFn: (input: { id: string; reportId: string }) => api.unlinkEventReport(input), - onSuccess: (_res, { id, reportId }) => + onSuccess: (_res, { id }) => Promise.all([ invalidateEvents(qc, id), - qc.invalidateQueries({ queryKey: queryKeys.reports.detail(reportId) }), qc.invalidateQueries({ queryKey: queryKeys.reports.all }), ]), meta: { 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 529ebda..ad4971e 100644 --- a/apps/admin/src/features/home/home-preview-presentation.test.ts +++ b/apps/admin/src/features/home/home-preview-presentation.test.ts @@ -1,31 +1,31 @@ import { describe, expect, it } from "vitest" import { - getMailPreviewPresentation, - getModerationPreviewPresentation, + mailPreviewView, + moderationPreviewView, } from "./home-preview-presentation" describe("home preview presentation", () => { it("leads the mail tile with the server-side outreach unread total", () => { - expect(getMailPreviewPresentation(7)).toEqual({ + expect(mailPreviewView(7)).toEqual({ lead: 7, unit: "unread outreach messages", }) - expect(getMailPreviewPresentation(1).unit).toBe("unread outreach message") + expect(mailPreviewView(1).unit).toBe("unread outreach message") }) it("leads the moderation tile with a label, never the preview length", () => { - expect(getModerationPreviewPresentation()).toEqual({ + expect(moderationPreviewView()).toEqual({ lead: null, unit: "user reports, held media, clusters and appeals", }) }) it("leads the moderation tile with the server-side queue total when the summary carries one", () => { - expect(getModerationPreviewPresentation(4)).toEqual({ + expect(moderationPreviewView(4)).toEqual({ lead: 4, unit: "queued · user reports, held media, clusters and appeals", }) - expect(getModerationPreviewPresentation(0).lead).toBe(0) + expect(moderationPreviewView(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 499c0f7..b7d4377 100644 --- a/apps/admin/src/features/home/home-preview-presentation.ts +++ b/apps/admin/src/features/home/home-preview-presentation.ts @@ -1,4 +1,4 @@ -export function getMailPreviewPresentation(outreachUnread: number) { +export function mailPreviewView(outreachUnread: number) { return { lead: outreachUnread, unit: outreachUnread === 1 ? "unread outreach message" : "unread outreach messages", @@ -9,7 +9,7 @@ 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) { +export function moderationPreviewView(queueTotal?: number) { if (queueTotal === undefined) return { lead: null, unit: MODERATION_MIX } return { lead: queueTotal, unit: `queued · ${MODERATION_MIX}` } } diff --git a/apps/admin/src/features/home/home-preview-rows.ts b/apps/admin/src/features/home/home-preview-rows.ts index 9f8efa3..312a5de 100644 --- a/apps/admin/src/features/home/home-preview-rows.ts +++ b/apps/admin/src/features/home/home-preview-rows.ts @@ -14,6 +14,8 @@ import { import { Icons, type IconComponent } from "@/components/icons" import type { Hue } from "@/features/analytics/analytics-charts" +import { inboxFocusId } from "@/features/mail/mail-page-state" +import { formatCompactCount } from "@/lib/display" import { EMPTY_VALUE } from "@/lib/empty-value" import { reportStatusView } from "@/lib/report-status" @@ -30,17 +32,8 @@ export interface PeekItem { focusId: string } -const POPULATION_FORMAT = new Intl.NumberFormat("en", { - notation: "compact", - maximumFractionDigits: 1, -}) - -function compactPopulation(population: number): string { - return population > 0 ? POPULATION_FORMAT.format(population) : "" -} - export function discoveryRow(task: DiscoveryTaskDTO): PeekItem { - const population = compactPopulation(task.pop) + const population = task.pop > 0 ? formatCompactCount(task.pop) : "" return { kind: "pin", cat: task.category, @@ -93,7 +86,7 @@ function inboxRow(email: InboundEmailListItemDTO): PeekItem { title: email.from || email.recipient, meta: email.subject || "(no subject)", age: relativeAgo(email.ts), - focusId: `inbox:${email.id}`, + focusId: inboxFocusId(email.id), } } diff --git a/apps/admin/src/features/home/home-summaries.ts b/apps/admin/src/features/home/home-summaries.ts index f5edafb..0412445 100644 --- a/apps/admin/src/features/home/home-summaries.ts +++ b/apps/admin/src/features/home/home-summaries.ts @@ -3,18 +3,18 @@ import type { HomeSummaryResponse } from "@civfix/shared" import type { Hue } from "@/features/analytics/analytics-charts" import { - getMailPreviewPresentation, - getModerationPreviewPresentation, + mailPreviewView, + moderationPreviewView, } from "@/features/home/home-preview-presentation" import type { PageId } from "@/store/ui-store" -export interface SectionStat { +interface SectionStat { k: string v: ReactNode tone?: "warn" | "alert" | null } -export interface SectionMetric { +interface SectionMetric { k: string v: ReactNode } @@ -139,7 +139,7 @@ export function mailSection( ): SectionSummary { const needsAction = data?.mail.needsAction ?? 0 const inboxUnread = data?.inboxUnread - const presentation = base.lead === null ? NO_COUNT : getMailPreviewPresentation(base.lead) + const presentation = base.lead === null ? NO_COUNT : mailPreviewView(base.lead) return { ...base, lead: presentation.lead, @@ -157,7 +157,7 @@ export function mailSection( } export function moderationPresentation(data: HomeSummaryResponse | undefined) { - return data ? getModerationPreviewPresentation(data.moderationQueue) : NO_COUNT + return data ? moderationPreviewView(data.moderationQueue) : NO_COUNT } export function moderationSection(lead: number | null, unit: string): SectionSummary { diff --git a/apps/admin/src/features/home/preview-list.tsx b/apps/admin/src/features/home/preview-list.tsx index 9133a60..8f3ad12 100644 --- a/apps/admin/src/features/home/preview-list.tsx +++ b/apps/admin/src/features/home/preview-list.tsx @@ -5,25 +5,15 @@ import { LoadingState, ErrorState } from "@/components/shared/data-states" import type { PeekItem } from "@/features/home/home-preview-rows" import type { PreviewState } from "@/features/home/home-preview-state" import { categoryPinSrc } from "@/lib/category" -import { INITIALS_MAX_LETTERS } from "@/lib/display" +import { initials } from "@/lib/display" import { useNav, type PageId } from "@/store/ui-store" -function initials(name: string): string { - return name - .split(" ") - .map((word) => word[0] ?? "") - .slice(0, INITIALS_MAX_LETTERS) - .join("") - .toUpperCase() -} - function PeekGlyph({ item }: { item: PeekItem }) { if (item.kind === "pin") { const src = item.cat ? categoryPinSrc(item.cat) : null return ( {src ? ( - // eslint-disable-next-line @next/next/no-img-element ) : ( diff --git a/apps/admin/src/features/home/use-home-tiles.ts b/apps/admin/src/features/home/use-home-tiles.ts index 3534994..a64cf98 100644 --- a/apps/admin/src/features/home/use-home-tiles.ts +++ b/apps/admin/src/features/home/use-home-tiles.ts @@ -2,7 +2,7 @@ import * as React from "react" -import { useHomeSummary } from "@/hooks/use-admin-home" +import { useHomeSummary } from "@/features/home/use-home" import { useDiscoveryList } from "@/features/discovery/use-discovery" import { useReportList } from "@/features/reports/use-reports" import { useEventList } from "@/features/events/use-events" diff --git a/apps/admin/src/hooks/use-admin-home.ts b/apps/admin/src/features/home/use-home.ts similarity index 100% rename from apps/admin/src/hooks/use-admin-home.ts rename to apps/admin/src/features/home/use-home.ts diff --git a/apps/admin/src/features/hosts/host-detail.tsx b/apps/admin/src/features/hosts/host-detail.tsx index 260d4d4..5629964 100644 --- a/apps/admin/src/features/hosts/host-detail.tsx +++ b/apps/admin/src/features/hosts/host-detail.tsx @@ -6,8 +6,10 @@ import type { AdminBroadcastListItemDTO, AdminHostListItemDTO } from "@civfix/sh import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" -import { promptDialog } from "@/components/shared/dialog" +import { LoadMoreButton } from "@/components/shared/section-list" +import { promptReason } from "@/components/shared/dialog" import { formatDateTime } from "@/lib/dates" +import { flatPages } from "@/lib/infinite" import { BroadcastLog } from "@/features/hosts/broadcast-log" import { eventsFromBroadcasts, @@ -15,10 +17,10 @@ import { type HostActivityWindow, type HostEventRef, } from "@/features/hosts/host-window" -import { useAdminBroadcastsInfinite, useSetHostMessagingSuspended } from "@/features/hosts/use-hosts" +import { useBroadcastListInfinite, useSetHostMessagingSuspended } from "@/features/hosts/use-hosts" import { useNav } from "@/store/ui-store" -type BroadcastLogQuery = ReturnType +type BroadcastLogQuery = ReturnType function HostDetailHead({ row }: { row: AdminHostListItemDTO }) { const suspended = row.messagingSuspended @@ -105,16 +107,7 @@ function BroadcastLogSection({ ) : ( <> - {logQuery.hasNextPage && ( - - )} + )}
@@ -195,24 +188,22 @@ function HostMessagingActions({ row }: { row: AdminHostListItemDTO }) { const onToggleSuspended = async () => { const next = !suspended - const reason = await promptDialog({ + const reason = await promptReason({ title: next ? `Suspend messaging for ${row.host.name}?` : `Restore messaging for ${row.host.name}?`, body: next ? "Every broadcast this host composes, schedules or sends is refused until messaging is restored. In-flight sends stop mid-chunk. The reason is written to the audit log." : "The host can compose and send broadcasts again, subject to the normal caps. The reason is written to the audit log.", - label: "Reason (required)", placeholder: next ? "Repeated off-topic broadcasts to event attendees…" : "Reviewed with the host; the pattern has stopped…", confirmLabel: next ? "Suspend messaging" : "Restore messaging", - required: true, danger: next, }) - if (reason === null || reason.trim() === "") return + if (reason === null) return suspend.mutate({ - request: { id: row.host.id, suspended: next, reason: reason.trim() }, + request: { id: row.host.id, suspended: next, reason }, hostName: row.host.name, }) } @@ -252,9 +243,9 @@ export function HostDetail({ () => hostBroadcastParams(row.host.id, activityWindow), [row.host.id, activityWindow], ) - const logQuery = useAdminBroadcastsInfinite(logParams) + const logQuery = useBroadcastListInfinite(logParams) const broadcasts = React.useMemo( - () => logQuery.data?.pages.flatMap((page) => page.items) ?? [], + () => flatPages(logQuery.data), [logQuery.data], ) const events = React.useMemo(() => eventsFromBroadcasts(broadcasts), [broadcasts]) diff --git a/apps/admin/src/features/hosts/host-list-pane.tsx b/apps/admin/src/features/hosts/host-list-pane.tsx index c9d4848..e02283d 100644 --- a/apps/admin/src/features/hosts/host-list-pane.tsx +++ b/apps/admin/src/features/hosts/host-list-pane.tsx @@ -4,12 +4,10 @@ import type { AdminHostListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" -import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { ListCard, ListStates, LoadMoreButton } from "@/components/shared/section-list" import { HostRow } from "@/features/hosts/host-row" import { HOST_ACTIVITY_WINDOW_DAYS } from "@/features/hosts/host-window" -import type { useAdminHostsInfinite } from "@/features/hosts/use-hosts" - -type HostListQuery = ReturnType +import type { useHostListInfinite } from "@/features/hosts/use-hosts" export function HostListPane({ listQuery, @@ -17,59 +15,42 @@ export function HostListPane({ selectedId, onSelect, }: { - listQuery: HostListQuery + listQuery: ReturnType rows: AdminHostListItemDTO[] selectedId: string | null onSelect: (id: string) => void }) { return ( -
-
-

Hosts

-
- {listQuery.isSuccess && ( - + {rows.length} {listQuery.hasNextPage ? "+" : ""} - - )} -
-
- {listQuery.isLoading ? ( - - ) : listQuery.isError ? ( - listQuery.refetch()} /> - ) : ( - <> - {rows.length === 0 ? ( - } - /> - ) : ( - rows.map((row) => ( - onSelect(row.host.id)} - /> - )) - )} - {listQuery.hasNextPage && ( - - )} + ) : undefined + } + > + + {rows.length === 0 ? ( + } + /> + ) : ( + rows.map((row) => ( + + )) )} -
-
+ + + ) } diff --git a/apps/admin/src/features/hosts/host-row.tsx b/apps/admin/src/features/hosts/host-row.tsx index 6f70ab7..db1c437 100644 --- a/apps/admin/src/features/hosts/host-row.tsx +++ b/apps/admin/src/features/hosts/host-row.tsx @@ -1,19 +1,20 @@ "use client" +import * as React from "react" import type { AdminHostListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" import { formatDateTime } from "@/lib/dates" -export function HostRow({ +export const HostRow = React.memo(function HostRow({ row, selected, - onClick, + onSelect, }: { row: AdminHostListItemDTO selected: boolean - onClick: () => void + onSelect: (id: string) => void }) { return (
onSelect(row.host.id)} onKeyDown={(e) => { if (!isKeyboardActivationKey(e.key)) return e.preventDefault() - onClick() + onSelect(row.host.id) }} >
@@ -62,4 +63,4 @@ export function HostRow({
) -} +}) diff --git a/apps/admin/src/features/hosts/hosts-page.tsx b/apps/admin/src/features/hosts/hosts-page.tsx index 6600624..20aff17 100644 --- a/apps/admin/src/features/hosts/hosts-page.tsx +++ b/apps/admin/src/features/hosts/hosts-page.tsx @@ -5,8 +5,10 @@ import type { AdminHostListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" +import { SearchBox } from "@/components/shared/section-list" import { useDebounced } from "@/hooks/use-debounced" -import { SEARCH_DEBOUNCE_MS } from "@/lib/timing" +import { useSelection } from "@/hooks/use-selection" +import { flatPages } from "@/lib/infinite" import { HostDetail } from "@/features/hosts/host-detail" import { HostListPane } from "@/features/hosts/host-list-pane" import { @@ -17,7 +19,7 @@ import { type HostActivityWindow, type HostFilter, } from "@/features/hosts/host-window" -import { useAdminHostsInfinite } from "@/features/hosts/use-hosts" +import { useHostListInfinite } from "@/features/hosts/use-hosts" import type { SectionPageProps } from "@/components/shell/page-registry" const FILTER_LABEL: Record = { @@ -28,38 +30,7 @@ const FILTER_LABEL: Record = { const FILTER_OPTIONS = HOST_FILTERS.map((value) => ({ value, label: FILTER_LABEL[value] })) -// Only the first load picks a host on the operator's behalf, and a deep-linked host is never -// replaced. There is no single-host read, so a pick that a filter or search leaves out clears, and so -// does any host that drops out of the same list after a refetch (a suspend under the Active chip): the -// action buttons never land on a host nobody chose. Decided only on data fetched for the current params. -function useHostSelection( - focusId: string | null, - listQuery: ReturnType, - rows: AdminHostListItemDTO[], - listKey: string, -) { - const [selectedId, setSelectedId] = React.useState(focusId) - const [autoPick, setAutoPick] = React.useState(focusId === null) - - React.useEffect(() => { - if (focusId) setSelectedId(focusId) - }, [focusId]) - const seenIn = React.useRef<{ id: string; list: string } | null>(null) - React.useEffect(() => { - if (!listQuery.isSuccess || listQuery.isFetching) return - if (selectedId === null) { - if (autoPick && rows.length) setSelectedId(rows[0]!.host.id) - return - } - setAutoPick(false) - if (rows.some((row) => row.host.id === selectedId)) seenIn.current = { id: selectedId, list: listKey } - else if (selectedId !== focusId || (seenIn.current?.id === selectedId && seenIn.current.list === listKey)) { - setSelectedId(null) - } - }, [listQuery.isSuccess, listQuery.isFetching, rows, selectedId, focusId, listKey, autoPick]) - - return [selectedId, setSelectedId] as const -} +const hostId = (row: AdminHostListItemDTO) => row.host.id function HostDetailPane({ selected, @@ -95,20 +66,27 @@ export function HostsPage({ focusId }: SectionPageProps) { const [filter, setFilter] = React.useState("all") const [query, setQuery] = React.useState("") - const debouncedQuery = useDebounced(query, SEARCH_DEBOUNCE_MS) + const debouncedQuery = useDebounced(query) const activityWindow = React.useMemo(() => hostActivityWindow(), []) const listParams = React.useMemo( () => hostListParams(filter, debouncedQuery), [filter, debouncedQuery], ) - const listQuery = useAdminHostsInfinite(listParams) + const listQuery = useHostListInfinite(listParams) const rows = React.useMemo( - () => listQuery.data?.pages.flatMap((page) => page.items) ?? [], + () => flatPages(listQuery.data), [listQuery.data], ) - const [selectedId, setSelectedId] = useHostSelection(focusId, listQuery, rows, JSON.stringify(listParams)) - - const selected = rows.find((row) => row.host.id === selectedId) ?? null + // There is no single-host read, so a pick that a filter or search leaves out clears too, except the + // deep-linked host: the action buttons never land on a host nobody chose. + const { selectedId, setSelectedId, selectedItem: selected } = useSelection({ + focusId, + list: listQuery, + items: rows, + getId: hostId, + listKey: JSON.stringify(listParams), + readableById: false, + }) const missingLink = selected === null && selectedId !== null && selectedId === focusId && listQuery.isSuccess @@ -134,16 +112,12 @@ export function HostsPage({ focusId }: SectionPageProps) { onChange={(value) => setFilter(value as HostFilter)} />
-
- - setQuery(e.target.value)} - /> -
+
diff --git a/apps/admin/src/features/hosts/use-hosts.ts b/apps/admin/src/features/hosts/use-hosts.ts index 2ef5e24..05ff0d6 100644 --- a/apps/admin/src/features/hosts/use-hosts.ts +++ b/apps/admin/src/features/hosts/use-hosts.ts @@ -3,40 +3,27 @@ import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query" import type { AdminBroadcastListQuery, - AdminBroadcastListResponse, AdminHostListQuery, - AdminHostListResponse, SetHostMessagingSuspendedRequest, SetHostMessagingSuspendedResponse, } from "@civfix/shared" import { api } from "@/lib/api" -import { queryKeys } from "@/lib/query" +import { infiniteListOptions } from "@/lib/infinite" +import { invalidateKeys, queryKeys } from "@/lib/query" -export function useAdminHostsInfinite(params: AdminHostListQuery) { - return useInfiniteQuery({ - queryKey: queryKeys.hosts.list(params), - queryFn: ({ pageParam }) => - api.adminListHosts({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) +export function useHostListInfinite(params: AdminHostListQuery) { + return useInfiniteQuery( + infiniteListOptions(queryKeys.hosts.list(params), params, (input) => api.adminListHosts(input)), + ) } -export function useAdminBroadcastsInfinite(params: AdminBroadcastListQuery) { - return useInfiniteQuery({ - queryKey: queryKeys.hosts.broadcasts(params), - queryFn: ({ pageParam }) => - api.adminListBroadcasts({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) +export function useBroadcastListInfinite(params: AdminBroadcastListQuery) { + return useInfiniteQuery( + infiniteListOptions(queryKeys.hosts.broadcasts(params), params, (input) => + api.adminListBroadcasts(input), + ), + ) } export interface SetHostMessagingSuspendedVariables { @@ -50,11 +37,11 @@ export function useSetHostMessagingSuspended() { mutationFn: ({ request }: SetHostMessagingSuspendedVariables) => api.adminSetHostMessagingSuspended(request), onSuccess: (_res, { request: { id } }) => - Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.hosts.all }), - qc.invalidateQueries({ queryKey: queryKeys.users.detail(id) }), - qc.invalidateQueries({ queryKey: queryKeys.users.all }), - qc.invalidateQueries({ queryKey: queryKeys.audit.all }), + invalidateKeys(qc, [ + queryKeys.hosts.all, + queryKeys.users.detail(id), + queryKeys.users.all, + queryKeys.audit.all, ]), meta: { successMessage: ( diff --git a/apps/admin/src/features/inbox/attachment-chip.tsx b/apps/admin/src/features/inbox/attachment-chip.tsx index 0ee1597..15da84a 100644 --- a/apps/admin/src/features/inbox/attachment-chip.tsx +++ b/apps/admin/src/features/inbox/attachment-chip.tsx @@ -8,7 +8,7 @@ import { isWebUrl } from "@/lib/external-url" // An attachment key comes from mail the platform received, so only an http(s) url becomes a link; // any other scheme (javascript:, data:) or a bare storage key renders inert. -export function AttachmentChip({ attachment }: { attachment: MailAttachment }) { +function AttachmentChip({ attachment }: { attachment: MailAttachment }) { if (!isWebUrl(attachment.key)) { return ( diff --git a/apps/admin/src/features/inbox/inbox-views.tsx b/apps/admin/src/features/inbox/inbox-views.tsx index 98677e9..0db75f0 100644 --- a/apps/admin/src/features/inbox/inbox-views.tsx +++ b/apps/admin/src/features/inbox/inbox-views.tsx @@ -13,11 +13,12 @@ import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { formatPreciseDateTime } from "@/lib/dates" import { AttachmentList } from "@/features/inbox/attachment-chip" import { useInboxMessage, useSetInboxStatus } from "@/features/inbox/use-inbox" import { replyOriginLabel } from "@/features/inbox/inbox-feed" import { AuthVerdictBadge, PublicationBadge } from "@/features/mail/mail-badges" -import { MAIL_STATUS_CLS, tsTitle } from "@/features/mail/mail-presentation" +import { MAIL_STATUS_CLS } from "@/features/mail/mail-presentation" const INBOUND_STATUS_CLS: Record = { unread: "status-flag", @@ -29,10 +30,12 @@ export function InboxRow({ item, selected, onClick, + now = Date.now(), }: { item: InboxFeedItemDTO selected: boolean onClick: () => void + now?: number }) { const isEmail = item.source === "email" return ( @@ -56,8 +59,8 @@ export function InboxRow({ {(isEmail ? item.from : item.org || item.from) || "(unknown sender)"} - - {relativeAgo(item.ts)} + + {relativeAgo(item.ts, now)}
diff --git a/apps/admin/src/features/inbox/use-inbox.test.ts b/apps/admin/src/features/inbox/use-inbox.test.ts index a3a4b8b..8d7578a 100644 --- a/apps/admin/src/features/inbox/use-inbox.test.ts +++ b/apps/admin/src/features/inbox/use-inbox.test.ts @@ -58,7 +58,7 @@ describe("inbox feed query", () => { const options = inboxFeedQueryOptions({ filter: "all" }) await qc.fetchInfiniteQuery(options) - invalidateMail(qc, "t1") + void invalidateMail(qc, "t1") expect(qc.getQueryState(options.queryKey)?.isInvalidated).toBe(true) }) diff --git a/apps/admin/src/features/inbox/use-inbox.ts b/apps/admin/src/features/inbox/use-inbox.ts index 8ea7bd4..8994617 100644 --- a/apps/admin/src/features/inbox/use-inbox.ts +++ b/apps/admin/src/features/inbox/use-inbox.ts @@ -1,11 +1,11 @@ "use client" import { - infiniteQueryOptions, useInfiniteQuery, useMutation, useQuery, useQueryClient, + type QueryClient, } from "@tanstack/react-query" import type { GetInboxMessageResponse, @@ -17,7 +17,8 @@ import type { import { attachmentRefreshInterval } from "@/features/inbox/attachments" import { api } from "@/lib/api" -import { queryKeys } from "@/lib/query" +import { infiniteListOptions } from "@/lib/infinite" +import { invalidateKeys, queryKeys } from "@/lib/query" export function useInboxList(params: InboxListQuery) { return useQuery({ @@ -27,29 +28,15 @@ export function useInboxList(params: InboxListQuery) { } export function useInboxListInfinite(params: InboxListQuery) { - return useInfiniteQuery({ - queryKey: queryKeys.inbox.list(params), - queryFn: ({ pageParam }) => - api.listInbox({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) + return useInfiniteQuery( + infiniteListOptions(queryKeys.inbox.list(params), params, (input) => api.listInbox(input)), + ) } export function inboxFeedQueryOptions(params: InboxFeedQuery) { - return infiniteQueryOptions({ - queryKey: queryKeys.inbox.feed(params), - queryFn: ({ pageParam }) => - api.listInboxFeed({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) + return infiniteListOptions(queryKeys.inbox.feed(params), params, (input) => + api.listInboxFeed(input), + ) } export function useInboxFeedInfinite(params: InboxFeedQuery) { @@ -65,11 +52,11 @@ export function useInboxMessage(id: string | null) { }) } -function invalidateInbox(qc: ReturnType, id?: string) { - return Promise.all([ - id ? qc.invalidateQueries({ queryKey: queryKeys.inbox.detail(id) }) : null, - qc.invalidateQueries({ queryKey: queryKeys.inbox.all }), - qc.invalidateQueries({ queryKey: queryKeys.home.all }), +function invalidateInbox(qc: QueryClient, id?: string) { + return invalidateKeys(qc, [ + id ? queryKeys.inbox.detail(id) : null, + queryKeys.inbox.all, + queryKeys.home.all, ]) } diff --git a/apps/admin/src/features/mail/mail-list-pane.test.tsx b/apps/admin/src/features/mail/mail-list-pane.test.tsx new file mode 100644 index 0000000..be74e26 --- /dev/null +++ b/apps/admin/src/features/mail/mail-list-pane.test.tsx @@ -0,0 +1,91 @@ +import { act, render, screen } from "@testing-library/react" +import type { InboxFeedEmailItemDTO, MailThreadListItemDTO } from "@civfix/shared" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { MINUTE_MS } from "@/lib/timing" +import { MailListPane } from "@/features/mail/mail-list-pane" + +const NOW = Date.parse("2026-09-20T15:00:00.000Z") +const FIVE_MINUTES_AGO = new Date(NOW - 5 * MINUTE_MS).toISOString() + +const THREAD = { + id: "t-1", + dir: "out", + from: "outreach@civfix.org", + to: "works@oaklandca.gov", + org: "Oakland Public Works", + subject: "Pothole on 5th", + preview: "Following up on the report", + ts: FIVE_MINUTES_AGO, + unread: false, + status: "sent", + jurisdictionGeoid: null, + reportId: null, +} satisfies MailThreadListItemDTO + +const EMAIL = { + source: "email", + id: "i-1", + from: "resident@example.com", + recipient: "support@civfix.org", + localPart: "support", + subject: "Photos of the dumping", + preview: "See attached", + ts: FIVE_MINUTES_AGO, + status: "unread", + unread: true, + hasAttachments: false, +} satisfies InboxFeedEmailItemDTO + +const SETTLED_LIST = { + isLoading: false, + isError: false, + error: null, + refetch: () => undefined, + hasNextPage: false, + isFetchingNextPage: false, + fetchNextPage: () => undefined, +} + +function renderPane(outreach: boolean) { + return render( + undefined} + />, + ) +} + +describe("MailListPane row age labels", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it.each([ + ["outreach thread", true], + ["inbox feed", false], + ])("advances the %s age as the clock moves, with no other change", (_label, outreach) => { + const { container } = renderPane(outreach) + const age = () => container.querySelector(".mail-ts")?.textContent + expect(screen.getAllByRole("button")).toHaveLength(1) + expect(age()).toBe("5m") + + act(() => { + vi.advanceTimersByTime(MINUTE_MS) + }) + expect(age()).toBe("6m") + }) +}) diff --git a/apps/admin/src/features/mail/mail-list-pane.tsx b/apps/admin/src/features/mail/mail-list-pane.tsx index e387769..c689990 100644 --- a/apps/admin/src/features/mail/mail-list-pane.tsx +++ b/apps/admin/src/features/mail/mail-list-pane.tsx @@ -1,5 +1,6 @@ "use client" +import * as React from "react" import { INBOX_FEED_FILTER_LABELS, type InboxFeedFilter, @@ -10,29 +11,26 @@ import { import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" -import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { + ListCard, + ListStates, + LoadMoreButton, + type ListLoadState, + type NextPageState, +} from "@/components/shared/section-list" +import { useNow } from "@/hooks/use-now" import { INBOX_EMPTY_COPY, feedKey } from "@/features/inbox/inbox-feed" import { InboxRow } from "@/features/inbox/inbox-views" import { outreachBoxLabel, type MailBox } from "@/features/mail/mail-page-state" import { MailRow } from "@/features/mail/mail-row" -interface ListQueryState { - isLoading: boolean - isError: boolean - error: unknown - refetch: () => unknown - hasNextPage: boolean - isFetchingNextPage: boolean - fetchNextPage: () => unknown -} - interface MailListPaneProps { outreach: boolean box: MailBox feedFilter: InboxFeedFilter searchTerm: string | undefined stats: MailStatsResponse | undefined - listQuery: ListQueryState + listQuery: ListLoadState & NextPageState activeCount: number mailItems: MailThreadListItemDTO[] feedItems: InboxFeedItemDTO[] @@ -66,6 +64,22 @@ function EmptyList({ ) } +const FeedRow = React.memo(function FeedRow({ + item, + selected, + onSelect, + now, +}: { + item: InboxFeedItemDTO + selected: boolean + onSelect: (key: string) => void + now: number +}) { + return ( + onSelect(feedKey(item))} now={now} /> + ) +}) + function ListRows({ outreach, mailItems, @@ -73,45 +87,32 @@ function ListRows({ selectedId, onSelect, }: Pick) { + const now = useNow() if (outreach) { return mailItems.map((t) => ( - onSelect(t.id)} /> + )) } return feedItems.map((item) => { const key = feedKey(item) return ( - onSelect(key)} /> + ) }) } function ListBody(props: MailListPaneProps) { const { outreach, feedFilter, searchTerm, listQuery, activeCount } = props - if (listQuery.isLoading) { - return - } - if (listQuery.isError) { - return listQuery.refetch()} /> - } - if (activeCount === 0) { - return - } return ( - <> + } + > - {listQuery.hasNextPage && ( - - )} - + + ) } @@ -124,15 +125,11 @@ export function MailListPane(props: MailListPaneProps) { : `${activeCount}${listQuery.hasNextPage ? "+" : ""}` return ( -
-
-

{outreach ? outreachBoxLabel(box) : INBOX_FEED_FILTER_LABELS[feedFilter]}

-
- {countLabel} -
-
- -
-
+ + + ) } diff --git a/apps/admin/src/features/mail/mail-page-state.test.ts b/apps/admin/src/features/mail/mail-page-state.test.ts index ee33a95..5a5f08a 100644 --- a/apps/admin/src/features/mail/mail-page-state.test.ts +++ b/apps/admin/src/features/mail/mail-page-state.test.ts @@ -7,6 +7,7 @@ import { EMPTY_VALUE } from "@/lib/empty-value" import { correspondent, inboxFeedParams, + inboxFocusId, mailListParams, outreachBoxLabel, outreachParams, @@ -64,6 +65,11 @@ describe("parseFocus", () => { it("treats any other focus as an outreach thread id", () => { expect(parseFocus("t1")).toEqual({ folder: "outreach", id: "t1" }) }) + + it("reads back the focus id built for an inbox email", () => { + expect(inboxFocusId("abc")).toBe("inbox:abc") + expect(parseFocus(inboxFocusId("abc"))).toEqual({ folder: "inbox", id: "email:abc" }) + }) }) function message(overrides: Partial): MailMessageDTO { diff --git a/apps/admin/src/features/mail/mail-page-state.ts b/apps/admin/src/features/mail/mail-page-state.ts index 599d561..7ff9577 100644 --- a/apps/admin/src/features/mail/mail-page-state.ts +++ b/apps/admin/src/features/mail/mail-page-state.ts @@ -29,7 +29,7 @@ export function outreachBoxLabel(box: MailBox): string { return OUTREACH_BOXES.find((b) => b.value === box)?.label ?? "All" } -export function feedFilterOf(box: MailBox): InboxFeedFilter { +export function mailboxFeedFilter(box: MailBox): InboxFeedFilter { return isInboxFeedFilter(box) ? box : "all" } @@ -48,7 +48,11 @@ export function mailListParams(folder: Folder, box: MailBox, q: string | undefin } export function inboxFeedParams(folder: Folder, box: MailBox, q: string | undefined): InboxFeedQuery { - return folder === "inbox" ? { filter: feedFilterOf(box), q } : { filter: "all" } + return folder === "inbox" ? { filter: mailboxFeedFilter(box), q } : { filter: "all" } +} + +export function inboxFocusId(emailId: string): string { + return `${INBOX_FOCUS_PREFIX}${emailId}` } export function parseFocus(focusId: string | null): { folder: Folder; id: string | null } { diff --git a/apps/admin/src/features/mail/mail-page.tsx b/apps/admin/src/features/mail/mail-page.tsx index 87d7ca3..55179ab 100644 --- a/apps/admin/src/features/mail/mail-page.tsx +++ b/apps/admin/src/features/mail/mail-page.tsx @@ -11,6 +11,7 @@ import { import { Icons } from "@/components/icons" import { PageHead, FilterChips, type FilterOption } from "@/components/shared/page-primitives" +import { SearchBox } from "@/components/shared/section-list" import { INBOX_FEED_FILTER_ORDER } from "@/features/inbox/inbox-feed" import { ComposeModal, type ComposeInput } from "@/features/mail/compose-modal" import { ForwardTemplateModal } from "@/features/mail/forward-template-modal" @@ -91,16 +92,12 @@ function MailToolbar({ onChange={(value) => onBoxChange(value as MailBox)} />
-
- - onQueryChange(e.target.value)} - /> -
+
) } diff --git a/apps/admin/src/features/mail/mail-presentation.ts b/apps/admin/src/features/mail/mail-presentation.ts index 904ae33..881ade8 100644 --- a/apps/admin/src/features/mail/mail-presentation.ts +++ b/apps/admin/src/features/mail/mail-presentation.ts @@ -10,12 +10,6 @@ export const MAIL_STATUS_CLS: Record = { bounced: "status-flag", } -export function tsTitle(value: string): string { - if (!value) return "" - const d = new Date(value) - return Number.isNaN(d.getTime()) ? "" : d.toLocaleString() -} - export const AUTH_VERDICT_VIEW: Record = { pass: { cls: "status-ok", title: "The sender's domain passed authentication." }, fail: { diff --git a/apps/admin/src/features/mail/mail-reader.tsx b/apps/admin/src/features/mail/mail-reader.tsx index 5be9c86..803fe1a 100644 --- a/apps/admin/src/features/mail/mail-reader.tsx +++ b/apps/admin/src/features/mail/mail-reader.tsx @@ -11,10 +11,11 @@ import { import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { formatPreciseDateTime } from "@/lib/dates" import { AttachmentList } from "@/features/inbox/attachment-chip" import { AuthVerdictBadge, PublicationBadge } from "@/features/mail/mail-badges" import { correspondent } from "@/features/mail/mail-page-state" -import { MAIL_STATUS_CLS, tsTitle } from "@/features/mail/mail-presentation" +import { MAIL_STATUS_CLS } from "@/features/mail/mail-presentation" import { useMailThread, useReplyMail, useResendMail, useSetMailStatus } from "@/features/mail/use-mail" import { WithheldReplyNote } from "@/features/mail/withheld-reply-note" import { useNav } from "@/store/ui-store" @@ -129,7 +130,7 @@ function ThreadMessage({ thread, message }: { thread: MailThreadDTO; message: Ma {message.who} {address && {address}} - + {relativeAgo(message.ts)} {isOut ? ( diff --git a/apps/admin/src/features/mail/mail-row.tsx b/apps/admin/src/features/mail/mail-row.tsx index d183871..6e93da0 100644 --- a/apps/admin/src/features/mail/mail-row.tsx +++ b/apps/admin/src/features/mail/mail-row.tsx @@ -1,24 +1,28 @@ "use client" +import * as React from "react" import { MAIL_STATUS_LABELS, relativeAgo, type MailThreadListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" -import { MAIL_STATUS_CLS, tsTitle } from "@/features/mail/mail-presentation" +import { formatPreciseDateTime } from "@/lib/dates" +import { MAIL_STATUS_CLS } from "@/features/mail/mail-presentation" function rowCorrespondent(item: MailThreadListItemDTO): string { if (item.org) return item.org return item.dir === "in" ? item.from || "(no sender)" : item.to || "(no recipient)" } -export function MailRow({ +export const MailRow = React.memo(function MailRow({ item, selected, - onClick, + onSelect, + now, }: { item: MailThreadListItemDTO selected: boolean - onClick: () => void + onSelect: (id: string) => void + now: number }) { return (
onSelect(item.id)} onKeyDown={(e) => { if (!isKeyboardActivationKey(e.key)) return e.preventDefault() - onClick() + onSelect(item.id) }} > @@ -39,8 +43,8 @@ export function MailRow({
{rowCorrespondent(item)} - - {relativeAgo(item.ts)} + + {relativeAgo(item.ts, now)}
{item.subject || "(no subject)"}
@@ -51,4 +55,4 @@ export function MailRow({
) -} +}) diff --git a/apps/admin/src/features/mail/use-mail.ts b/apps/admin/src/features/mail/use-mail.ts index 65da5d5..4ab5da4 100644 --- a/apps/admin/src/features/mail/use-mail.ts +++ b/apps/admin/src/features/mail/use-mail.ts @@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient, + type QueryClient, } from "@tanstack/react-query" import type { ComposeRequest, @@ -28,7 +29,8 @@ import type { import { attachmentRefreshInterval } from "@/features/inbox/attachments" import { PUBLISH_TOAST } from "@/features/mail/mail-presentation" import { api } from "@/lib/api" -import { queryKeys } from "@/lib/query" +import { infiniteListOptions } from "@/lib/infinite" +import { invalidateKeys, queryKeys } from "@/lib/query" export function useMailList(params: MailListQuery) { return useQuery({ @@ -38,16 +40,9 @@ export function useMailList(params: MailListQuery) { } export function useMailListInfinite(params: MailListQuery) { - return useInfiniteQuery({ - queryKey: queryKeys.mail.list(params), - queryFn: ({ pageParam }) => - api.listMail({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) + return useInfiniteQuery( + infiniteListOptions(queryKeys.mail.list(params), params, (input) => api.listMail(input)), + ) } export function useMailThread(id: string | null) { @@ -69,13 +64,13 @@ export function useMailStats() { }) } -export function invalidateMail(qc: ReturnType, id?: string) { - return Promise.all([ - id ? qc.invalidateQueries({ queryKey: queryKeys.mail.detail(id) }) : null, - qc.invalidateQueries({ queryKey: queryKeys.mail.all }), - qc.invalidateQueries({ queryKey: queryKeys.mail.stats }), - qc.invalidateQueries({ queryKey: queryKeys.inbox.all }), - qc.invalidateQueries({ queryKey: queryKeys.home.all }), +export function invalidateMail(qc: QueryClient, id?: string) { + return invalidateKeys(qc, [ + id ? queryKeys.mail.detail(id) : null, + queryKeys.mail.all, + queryKeys.mail.stats, + queryKeys.inbox.all, + queryKeys.home.all, ]) } @@ -134,7 +129,7 @@ export function useResendMail() { }) } -export function publishMailReplyOptions(qc: ReturnType) { +export function publishMailReplyOptions(qc: QueryClient) { return mutationOptions({ mutationFn: (input: PublishMailReplyRequest) => api.publishMailReply(input), onSuccess: (_res, { id }) => diff --git a/apps/admin/src/features/mail/use-mailbox.ts b/apps/admin/src/features/mail/use-mailbox.ts index 7af86d8..bbe3b75 100644 --- a/apps/admin/src/features/mail/use-mailbox.ts +++ b/apps/admin/src/features/mail/use-mailbox.ts @@ -6,7 +6,7 @@ import type { InboxFeedItemDTO } from "@civfix/shared" import { feedKey, resolveFeedSelection } from "@/features/inbox/inbox-feed" import { useInboxFeedInfinite, useSetInboxStatus } from "@/features/inbox/use-inbox" import { - feedFilterOf, + mailboxFeedFilter, inboxFeedParams, mailListParams, parseFocus, @@ -15,6 +15,10 @@ import { } from "@/features/mail/mail-page-state" import { useMailListInfinite, useMarkMailRead } from "@/features/mail/use-mail" import { SEARCH_DEBOUNCE_MS } from "@/lib/timing" +import { flatPages } from "@/lib/infinite" +import { useSelection } from "@/hooks/use-selection" + +const ownId = (id: string) => id function useMailSearch() { const [query, setQuery] = React.useState("") @@ -38,11 +42,11 @@ function useMailboxLists(folder: Folder, box: MailBox, searchTerm: string | unde const inboxFeedQuery = useInboxFeedInfinite(inboxFeedParams(folder, box, searchTerm)) const mailItems = React.useMemo( - () => mailListQuery.data?.pages.flatMap((p) => p.items) ?? [], + () => flatPages(mailListQuery.data), [mailListQuery.data], ) const feedItems = React.useMemo( - () => inboxFeedQuery.data?.pages.flatMap((p) => p.items) ?? [], + () => flatPages(inboxFeedQuery.data), [inboxFeedQuery.data], ) const feedByKey = React.useMemo( @@ -74,75 +78,29 @@ function useMailboxLists(folder: Folder, box: MailBox, searchTerm: string | unde } } -type MailboxLists = ReturnType - -// Only a fresh load of a folder picks a message on the operator's behalf, and a deep-linked message -// is never replaced. A pick that a filter or search leaves out stays open through its by-id reader; a -// pick that drops out of the same list after a refetch clears, so the pane never jumps to another -// message. Opening an unread message reads it, which is not the operator's action: until the list -// shows it read, dropping out (the Unread or Needs attention chip) counts as a filter drop-out and -// the message stays open. Decided only on data fetched for the current folder and filters. -function useSelectionFollowsList({ - selectedId, - setSelectedId, - listKey, - lists, - autoPickFirst, -}: { - selectedId: string | null - setSelectedId: (id: string | null) => void - listKey: string - lists: MailboxLists - autoPickFirst: boolean -}) { - const [autoPick, setAutoPick] = React.useState(autoPickFirst) - const seenIn = React.useRef<{ id: string; list: string } | null>(null) - const readOnOpen = React.useRef(null) - const { activeListQuery, activeIds, activeUnread } = lists - React.useEffect(() => { - if (!activeListQuery.isSuccess || activeListQuery.isFetching) return - if (selectedId === null) { - if (autoPick && activeIds.length) setSelectedId(activeIds[0]!) - return - } - setAutoPick(false) - if (activeIds.includes(selectedId)) { - seenIn.current = { id: selectedId, list: listKey } - if (readOnOpen.current === selectedId && !activeUnread.has(selectedId)) readOnOpen.current = null - } else if (seenIn.current?.id === selectedId && seenIn.current.list === listKey) { - if (readOnOpen.current === selectedId) { - readOnOpen.current = null - seenIn.current = null - } else { - setSelectedId(null) - } - } - }, [ - activeListQuery.isSuccess, - activeListQuery.isFetching, - activeIds, - activeUnread, - selectedId, - listKey, - autoPick, - setSelectedId, - ]) - return { readOnOpen, restartAutoPick: () => setAutoPick(true) } -} - export function useMailbox(focusId: string | null) { const initial = parseFocus(focusId) const [folder, setFolder] = React.useState(initial.folder) const [box, setBox] = React.useState("all") - const [selectedId, setSelectedId] = React.useState(initial.id) const [pickedFeedItem, setPickedFeedItem] = React.useState(null) const search = useMailSearch() const outreach = folder === "outreach" - const markRead = useMarkMailRead() - const setInboxStatus = useSetInboxStatus({ quiet: true }) + const { mutate: markRead } = useMarkMailRead() + const { mutate: setInboxStatus } = useSetInboxStatus({ quiet: true }) const lists = useMailboxLists(folder, box, search.searchTerm) const { mailItems, feedByKey } = lists + // Opening an unread message reads it, so the Unread or Needs attention chip would otherwise close it. + // A folder switch or a sent message is a fresh load, which picks again. + const { selectedId, setSelectedId, readOnOpen, restartAutoPick } = useSelection({ + focusId: initial.id, + syncFocus: false, + list: lists.activeListQuery, + items: lists.activeIds, + getId: ownId, + listKey: JSON.stringify([folder, box, search.searchTerm ?? null]), + unreadIds: lists.activeUnread, + }) React.useEffect(() => { if (!focusId) return @@ -150,18 +108,11 @@ export function useMailbox(focusId: string | null) { setFolder(focus.folder) setBox("all") setSelectedId(focus.id) - }, [focusId]) + }, [focusId, setSelectedId]) React.useEffect(() => { const listed = selectedId ? feedByKey.get(selectedId) : undefined if (listed) setPickedFeedItem(listed) }, [selectedId, feedByKey]) - const { readOnOpen, restartAutoPick } = useSelectionFollowsList({ - selectedId, - setSelectedId, - listKey: JSON.stringify([folder, box, search.searchTerm ?? null]), - lists, - autoPickFirst: initial.id === null, - }) const openFresh = (next: Folder) => { setFolder(next) @@ -176,7 +127,8 @@ export function useMailbox(focusId: string | null) { search.clear() } - const select = (id: string) => { + // Stable across keystrokes and picks, so the memoized list rows skip re-rendering. + const select = React.useCallback((id: string) => { setSelectedId(id) readOnOpen.current = null const readFailed = { @@ -188,22 +140,22 @@ export function useMailbox(focusId: string | null) { const row = mailItems.find((t) => t.id === id) if (!row?.unread) return readOnOpen.current = id - markRead.mutate({ id }, readFailed) + markRead({ id }, readFailed) return } const item = feedByKey.get(id) if (!item?.unread) return readOnOpen.current = id - if (item.source === "email") setInboxStatus.mutate({ id: item.id, status: "read" }, readFailed) - else markRead.mutate({ id: item.threadId }, readFailed) - } + if (item.source === "email") setInboxStatus({ id: item.id, status: "read" }, readFailed) + else markRead({ id: item.threadId }, readFailed) + }, [setSelectedId, readOnOpen, outreach, mailItems, feedByKey, markRead, setInboxStatus]) return { folder, outreach, box, setBox, - feedFilter: feedFilterOf(box), + feedFilter: mailboxFeedFilter(box), search, lists, selectedId, diff --git a/apps/admin/src/features/moderation/gov-claim-decision-bar.tsx b/apps/admin/src/features/moderation/gov-claim-decision-bar.tsx index 3b50ee1..4ae06e0 100644 --- a/apps/admin/src/features/moderation/gov-claim-decision-bar.tsx +++ b/apps/admin/src/features/moderation/gov-claim-decision-bar.tsx @@ -3,12 +3,12 @@ import type { GovClaimDTO } from "@civfix/shared" import { Icons } from "@/components/icons" -import { confirmDialog, promptDialog } from "@/components/shared/dialog" +import { confirmDialog, promptReason } from "@/components/shared/dialog" import { GOV_CHECKS, - govClaimApproveBlockedFor, + govClaimApproveBlockedMessage, govClaimApproveConfirmBody, - govClaimDecisionBlockedFor, + govClaimDecisionBlockedMessage, } from "@/features/moderation/gov-claim-presentation" import type { useApproveGovClaim, useRejectGovClaim } from "@/features/moderation/use-gov-claims" @@ -50,8 +50,8 @@ export function GovClaimDecisionBar({ busy: boolean onDecided: (id: string) => void }) { - const decisionBlocked = govClaimDecisionBlockedFor(claim.status) - const approveBlocked = govClaimApproveBlockedFor(claim) + const decisionBlocked = govClaimDecisionBlockedMessage(claim.status) + const approveBlocked = govClaimApproveBlockedMessage(claim) const onApprove = async () => { if (approveBlocked !== null) return @@ -65,18 +65,16 @@ export function GovClaimDecisionBar({ } const onReject = async () => { - const reason = await promptDialog({ + const reason = await promptReason({ title: `Reject ${claim.name}?`, body: "The reason is stored on the claim and written to the audit log.", - label: "Reason (required)", placeholder: "The directory listing does not show this person in that department…", confirmLabel: "Reject claim", - required: true, danger: true, }) if (reason === null) return reject.mutate( - { request: { id: claim.id, reason: reason.trim() }, claim }, + { request: { id: claim.id, reason }, claim }, { onSuccess: () => onDecided(claim.id) }, ) } diff --git a/apps/admin/src/features/moderation/gov-claim-presentation.test.ts b/apps/admin/src/features/moderation/gov-claim-presentation.test.ts index 893a521..7000bed 100644 --- a/apps/admin/src/features/moderation/gov-claim-presentation.test.ts +++ b/apps/admin/src/features/moderation/gov-claim-presentation.test.ts @@ -11,10 +11,10 @@ import { GOV_CHECKS, GOV_CLAIM_STATUS_VIEW, govCheckLabel, - govClaimApproveBlockedFor, + govClaimApproveBlockedMessage, govClaimApproveConfirmBody, govClaimApproveErrorMessage, - govClaimDecisionBlockedFor, + govClaimDecisionBlockedMessage, } from "./gov-claim-presentation" const PENDING_CHECK = { status: "pending", evidence: null, note: null } as const @@ -55,21 +55,21 @@ describe("gov claim presentation", () => { describe("gov claim decision availability", () => { it("blocks a second decision, matching the API's 409 on a non-pending claim", () => { - expect(govClaimDecisionBlockedFor("approved")).toBe("This claim was already approved.") - expect(govClaimDecisionBlockedFor("rejected")).toBe("This claim was already rejected.") + expect(govClaimDecisionBlockedMessage("approved")).toBe("This claim was already approved.") + expect(govClaimDecisionBlockedMessage("rejected")).toBe("This claim was already rejected.") }) it("allows the decision while the claim is still pending", () => { - expect(govClaimDecisionBlockedFor("pending")).toBeNull() + expect(govClaimDecisionBlockedMessage("pending")).toBeNull() }) it("blocks approve when the claim has no contact email to grant access on", () => { - expect(govClaimApproveBlockedFor(claim({ contactEmail: "" }))).toBe( + expect(govClaimApproveBlockedMessage(claim({ contactEmail: "" }))).toBe( "This claim has no contact email, so there is no account to grant government access to.", ) - expect(govClaimApproveBlockedFor(claim({ contactEmail: " " }))).not.toBeNull() - expect(govClaimApproveBlockedFor(claim())).toBeNull() - expect(govClaimApproveBlockedFor(claim({ status: "approved" }))).toBe( + expect(govClaimApproveBlockedMessage(claim({ contactEmail: " " }))).not.toBeNull() + expect(govClaimApproveBlockedMessage(claim())).toBeNull() + expect(govClaimApproveBlockedMessage(claim({ status: "approved" }))).toBe( "This claim was already approved.", ) }) diff --git a/apps/admin/src/features/moderation/gov-claim-presentation.ts b/apps/admin/src/features/moderation/gov-claim-presentation.ts index e23f447..1419123 100644 --- a/apps/admin/src/features/moderation/gov-claim-presentation.ts +++ b/apps/admin/src/features/moderation/gov-claim-presentation.ts @@ -65,14 +65,14 @@ export function verifyCheckRequest( * Why approve / reject are unavailable, or null when the claim can still be decided. The API refuses a * decision on a claim that is no longer pending (409), so the buttons say so rather than failing. */ -export function govClaimDecisionBlockedFor(status: GovClaimStatus): string | null { +export function govClaimDecisionBlockedMessage(status: GovClaimStatus): string | null { if (status === "approved") return "This claim was already approved." if (status === "rejected") return "This claim was already rejected." return null } -export function govClaimApproveBlockedFor(claim: GovClaimDTO): string | null { - const lifecycle = govClaimDecisionBlockedFor(claim.status) +export function govClaimApproveBlockedMessage(claim: GovClaimDTO): string | null { + const lifecycle = govClaimDecisionBlockedMessage(claim.status) if (lifecycle !== null) return lifecycle if (claim.contactEmail.trim() === "") { return "This claim has no contact email, so there is no account to grant government access to." diff --git a/apps/admin/src/features/moderation/gov-claims-section.tsx b/apps/admin/src/features/moderation/gov-claims-section.tsx index 1bfbc19..0bc7f51 100644 --- a/apps/admin/src/features/moderation/gov-claims-section.tsx +++ b/apps/admin/src/features/moderation/gov-claims-section.tsx @@ -1,13 +1,14 @@ "use client" import * as React from "react" -import type { GovClaimDTO, GovClaimListQuery } from "@civfix/shared" +import type { GovClaimListQuery } from "@civfix/shared" import { Icons } from "@/components/icons" import { FilterChips, EmptyState } from "@/components/shared/page-primitives" -import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { ListCard, ListStates, LoadMoreButton, SearchBox } from "@/components/shared/section-list" import { useDebounced } from "@/hooks/use-debounced" -import { SEARCH_DEBOUNCE_MS } from "@/lib/timing" +import { idOf, useSelection } from "@/hooks/use-selection" +import { flatPages } from "@/lib/infinite" import { GovClaimDetail, GovClaimRow } from "@/features/moderation/gov-claims-views" import { useGovClaimListInfinite } from "@/features/moderation/use-gov-claims" @@ -20,87 +21,29 @@ const GOV_CLAIM_FILTER_OPTIONS: { value: GovClaimFilter; label: string }[] = [ { value: "all", label: "All" }, ] -type GovClaimListQueryResult = ReturnType - -function GovClaimListItems({ - listQuery, - items, - selectedId, - onSelect, -}: { - listQuery: GovClaimListQueryResult - items: GovClaimDTO[] - selectedId: string | null - onSelect: (id: string) => void -}) { - if (listQuery.isLoading) return - if (listQuery.isError) { - return listQuery.refetch()} /> - } - if (items.length === 0) { - return ( - } - /> - ) - } - return ( - <> - {items.map((c) => ( - - ))} - {listQuery.hasNextPage && ( - - )} - - ) -} - export function GovClaimsSection() { const [filter, setFilter] = React.useState("pending") const [query, setQuery] = React.useState("") - const [selectedId, setSelectedId] = React.useState(null) - const debouncedQuery = useDebounced(query, SEARCH_DEBOUNCE_MS) + const debouncedQuery = useDebounced(query) const listParams: GovClaimListQuery = { ...(filter === "all" ? {} : { filter }), ...(debouncedQuery.trim() ? { q: debouncedQuery.trim() } : {}), } - const listKey = JSON.stringify(listParams) const listQuery = useGovClaimListInfinite(listParams) const items = React.useMemo( - () => listQuery.data?.pages.flatMap((p) => p.items) ?? [], + () => flatPages(listQuery.data), [listQuery.data], ) - // Only the first load picks a claim on the operator's behalf. A pick that a filter or search leaves - // out stays open (the detail reads it by id); a pick that drops out of the same list after a refetch - // clears, so the pane never jumps to another claim's live Approve and Reject buttons. A decision - // clears its claim outright, since the list may still hold it (All) or never did. Decided only on - // data fetched for the current params. - const [autoPick, setAutoPick] = React.useState(true) - const seenIn = React.useRef<{ id: string; list: string } | null>(null) - React.useEffect(() => { - if (!listQuery.isSuccess || listQuery.isFetching) return - if (selectedId === null) { - if (autoPick && items.length) setSelectedId(items[0]!.id) - return - } - setAutoPick(false) - if (items.some((x) => x.id === 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]) - const clearIfSelected = (id: string) => setSelectedId((current) => (current === id ? null : current)) + // A decision clears its claim outright, since the list may still hold it (All) or never did. + const { selectedId, setSelectedId, clearIfSelected } = useSelection({ + focusId: null, + list: listQuery, + items, + getId: idOf, + listKey: JSON.stringify(listParams), + }) return ( <> @@ -111,34 +54,34 @@ export function GovClaimsSection() { onChange={(v) => setFilter(v as GovClaimFilter)} />
-
- - setQuery(e.target.value)} - /> -
+
-
-
-

Gov claims

-
- {items.length} -
-
- -
-
+ + } + /> + } + > + {items.map((c) => ( + + ))} + + +
{selectedId ? ( diff --git a/apps/admin/src/features/moderation/gov-claims-views.tsx b/apps/admin/src/features/moderation/gov-claims-views.tsx index 2dfc2b7..64673f5 100644 --- a/apps/admin/src/features/moderation/gov-claims-views.tsx +++ b/apps/admin/src/features/moderation/gov-claims-views.tsx @@ -1,5 +1,6 @@ "use client" +import * as React from "react" import type { GovClaimDTO } from "@civfix/shared" import { Icons } from "@/components/icons" @@ -21,7 +22,7 @@ import { useVerifyGovClaimCheck, } from "@/features/moderation/use-gov-claims" -export function GovClaimRow({ +export const GovClaimRow = React.memo(function GovClaimRow({ item, selected, onSelect, @@ -66,7 +67,7 @@ export function GovClaimRow({
) -} +}) // Called by the detail pane rather than its cards: one busy flag spans the check toggles and the // decision buttons, and a decision's per-call onSuccess fires only while this hook stays mounted. diff --git a/apps/admin/src/features/moderation/moderation-decision-bar.tsx b/apps/admin/src/features/moderation/moderation-decision-bar.tsx index 4508949..f2e0c82 100644 --- a/apps/admin/src/features/moderation/moderation-decision-bar.tsx +++ b/apps/admin/src/features/moderation/moderation-decision-bar.tsx @@ -29,7 +29,7 @@ export function useModerationDecisions() { type ModerationDecisions = ReturnType -function approveVerbFor(item: Pick): string { +function approveVerbLabel(item: Pick): string { return item.kind === "user_report" ? "Keep" : "Approve" } @@ -49,7 +49,7 @@ export function ModerationDecisionBar({ onResolved: (id: string) => void }) { const { approve, remove, hold, appeal, busy } = decisions - const approveVerb = approveVerbFor(item) + const approveVerb = approveVerbLabel(item) const clearOnSuccess = { onSuccess: () => onResolved(item.id) } const onApprove = () => diff --git a/apps/admin/src/features/moderation/moderation-detail.tsx b/apps/admin/src/features/moderation/moderation-detail.tsx index b12a7a3..e44bea6 100644 --- a/apps/admin/src/features/moderation/moderation-detail.tsx +++ b/apps/admin/src/features/moderation/moderation-detail.tsx @@ -15,7 +15,7 @@ import { SignalsCard, SimilarItemsCard, } from "@/features/moderation/moderation-evidence-cards" -import { getModerationDestination } from "@/features/moderation/moderation-navigation" +import { moderationDestination } from "@/features/moderation/moderation-navigation" import { UserContextCard } from "@/features/moderation/moderation-user-context" import { kindIcon, moderationKindLabel, priorityView } from "@/features/moderation/moderation-views" import { useModerationItem } from "@/features/moderation/use-moderation" @@ -59,7 +59,7 @@ function ModerationHead({ item }: { item: ModerationItemDTO }) { function ReportedSubjectCard({ item }: { item: ModerationItemDTO }) { const nav = useNav() - const destination = getModerationDestination(item.destinationKind, item.destinationId) + const destination = moderationDestination(item.destinationKind, item.destinationId) return (
Report
diff --git a/apps/admin/src/features/moderation/moderation-evidence-cards.tsx b/apps/admin/src/features/moderation/moderation-evidence-cards.tsx index c1a4742..096c26b 100644 --- a/apps/admin/src/features/moderation/moderation-evidence-cards.tsx +++ b/apps/admin/src/features/moderation/moderation-evidence-cards.tsx @@ -37,7 +37,6 @@ export function HeldMediaCard({ title="Expand this image" onClick={() => openLightbox(heldImages, index, refreshMedia)} > - {/* eslint-disable-next-line @next/next/no-img-element */} {heldImages[index]?.alt {m.thumbUrl ? ( - // eslint-disable-next-line @next/next/no-img-element ) : ( diff --git a/apps/admin/src/features/moderation/moderation-navigation.test.ts b/apps/admin/src/features/moderation/moderation-navigation.test.ts index eeb9f71..eb255eb 100644 --- a/apps/admin/src/features/moderation/moderation-navigation.test.ts +++ b/apps/admin/src/features/moderation/moderation-navigation.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from "vitest" -import { getModerationDestination } from "./moderation-navigation" +import { moderationDestination } from "./moderation-navigation" -describe("getModerationDestination", () => { +describe("moderationDestination", () => { it("uses explicit report/event destinations and never guesses from a chat/photo subject", () => { - expect(getModerationDestination("report", "REPORT-1")).toEqual({ + expect(moderationDestination("report", "REPORT-1")).toEqual({ page: "reports", id: "REPORT-1", }) - expect(getModerationDestination("event", "EVENT-1")).toEqual({ + expect(moderationDestination("event", "EVENT-1")).toEqual({ page: "events", id: "EVENT-1", }) - expect(getModerationDestination(null, null)).toBeNull() - expect(getModerationDestination("report", null)).toBeNull() + expect(moderationDestination(null, null)).toBeNull() + expect(moderationDestination("report", null)).toBeNull() }) }) diff --git a/apps/admin/src/features/moderation/moderation-navigation.ts b/apps/admin/src/features/moderation/moderation-navigation.ts index 9dabe06..1d2016f 100644 --- a/apps/admin/src/features/moderation/moderation-navigation.ts +++ b/apps/admin/src/features/moderation/moderation-navigation.ts @@ -2,7 +2,7 @@ import type { ModerationDestinationKind } from "@civfix/shared" import type { PageId } from "@/store/ui-store" -export function getModerationDestination( +export function moderationDestination( kind: ModerationDestinationKind | null | undefined, id: string | null | undefined, ): { page: PageId; id: string } | null { diff --git a/apps/admin/src/features/moderation/moderation-queue.tsx b/apps/admin/src/features/moderation/moderation-queue.tsx index b013a57..be4918d 100644 --- a/apps/admin/src/features/moderation/moderation-queue.tsx +++ b/apps/admin/src/features/moderation/moderation-queue.tsx @@ -1,13 +1,14 @@ "use client" import * as React from "react" -import type { ModerationListItemDTO, ModerationListQuery } from "@civfix/shared" +import type { ModerationListQuery } from "@civfix/shared" import { Icons } from "@/components/icons" import { FilterChips, EmptyState } from "@/components/shared/page-primitives" -import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { ListCard, ListStates, LoadMoreButton, SearchBox } from "@/components/shared/section-list" import { useDebounced } from "@/hooks/use-debounced" -import { SEARCH_DEBOUNCE_MS } from "@/lib/timing" +import { idOf, useSelection } from "@/hooks/use-selection" +import { flatPages } from "@/lib/infinite" import { ModerationDetail } from "@/features/moderation/moderation-detail" import { ModerationRowMemo } from "@/features/moderation/moderation-row" import { useModerationListInfinite } from "@/features/moderation/use-moderation" @@ -26,92 +27,31 @@ const QUEUE_FILTER_OPTIONS: { value: QueueFilter; label: string }[] = [ { value: "high", label: "High" }, ] -type QueueListQuery = ReturnType - -function QueueListItems({ - listQuery, - items, - selectedId, - onSelect, -}: { - listQuery: QueueListQuery - items: ModerationListItemDTO[] - selectedId: string | null - onSelect: (id: string) => void -}) { - if (listQuery.isLoading) return - if (listQuery.isError) { - return listQuery.refetch()} /> - } - if (items.length === 0) { - return ( - } - /> - ) - } - return ( - <> - {items.map((m) => ( - - ))} - {listQuery.hasNextPage && ( - - )} - - ) -} - export function ModerationQueueSection({ focusId }: SectionPageProps) { const [filter, setFilter] = React.useState("all") const [query, setQuery] = React.useState("") - const [selectedId, setSelectedId] = React.useState(focusId) - const debouncedQuery = useDebounced(query, SEARCH_DEBOUNCE_MS) + const debouncedQuery = useDebounced(query) const listParams: ModerationListQuery = { ...(filter === "all" ? {} : { filter }), ...(debouncedQuery.trim() ? { q: debouncedQuery.trim() } : {}), } - const listKey = JSON.stringify(listParams) const listQuery = useModerationListInfinite(listParams) const items = React.useMemo( - () => listQuery.data?.pages.flatMap((p) => p.items) ?? [], + () => flatPages(listQuery.data), [listQuery.data], ) - React.useEffect(() => { - if (focusId) setSelectedId(focusId) - }, [focusId]) - // Only the first load picks an item on the operator's behalf, and a deep-linked item is never - // replaced. A pick that a filter or search leaves out stays open (the detail reads it by id); a pick - // that drops out of the same list after a refetch clears, so the pane never jumps to another item's - // live decision buttons. A decision clears its item outright: the detail carries no status, so a - // resolved item the list never held would otherwise keep live buttons. Decided only on data fetched - // for the current params. - 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.length) setSelectedId(items[0]!.id) - return - } - setAutoPick(false) - if (items.some((x) => x.id === 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]) - const clearIfSelected = (id: string) => setSelectedId((current) => (current === id ? null : current)) + // A decision clears its item outright: the detail carries no status, so a resolved item the list + // never held would otherwise keep live buttons. + const { selectedId, setSelectedId, clearIfSelected } = useSelection({ + focusId, + list: listQuery, + items, + getId: idOf, + listKey: JSON.stringify(listParams), + }) return ( <> @@ -122,34 +62,39 @@ export function ModerationQueueSection({ focusId }: SectionPageProps) { onChange={(v) => setFilter(v as QueueFilter)} />
-
- - setQuery(e.target.value)} - /> -
+
-
-
-

Queue

-
- {items.length} -
-
- -
-
+ + } + /> + } + > + {items.map((m) => ( + + ))} + + +
{selectedId ? ( diff --git a/apps/admin/src/features/moderation/moderation-user-context.tsx b/apps/admin/src/features/moderation/moderation-user-context.tsx index 057127a..343df87 100644 --- a/apps/admin/src/features/moderation/moderation-user-context.tsx +++ b/apps/admin/src/features/moderation/moderation-user-context.tsx @@ -3,27 +3,18 @@ import type { ModerationItemDTO } from "@civfix/shared" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" -import { INITIALS_MAX_LETTERS } from "@/lib/display" +import { initials } from "@/lib/display" import { useNav } from "@/store/ui-store" type ModerationUser = ModerationItemDTO["user"] const CONTEXT_AVATAR_BACKGROUND = "linear-gradient(135deg, var(--sky), var(--moss))" -function nameInitials(name: string): string { - return name - .split(" ") - .map((word) => word[0] ?? "") - .slice(0, INITIALS_MAX_LETTERS) - .join("") - .toUpperCase() -} - function UserContextHead({ user }: { user: ModerationUser }) { return (
- {nameInitials(user.name)} + {initials(user.name)}
{user.name}
diff --git a/apps/admin/src/features/moderation/use-gov-claims.ts b/apps/admin/src/features/moderation/use-gov-claims.ts index cc28bb1..ee71817 100644 --- a/apps/admin/src/features/moderation/use-gov-claims.ts +++ b/apps/admin/src/features/moderation/use-gov-claims.ts @@ -1,34 +1,35 @@ "use client" -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query" import type { ApproveGovClaimRequest, GetGovClaimResponse, GovClaimDTO, GovClaimListQuery, - GovClaimListResponse, RejectGovClaimRequest, VerifyCheckRequest, } from "@civfix/shared" import { api } from "@/lib/api" -import { queryKeys } from "@/lib/query" +import { infiniteListOptions } from "@/lib/infinite" +import { invalidateKeys, queryKeys } from "@/lib/query" import { govCheckLabel, govClaimApproveErrorMessage, } from "@/features/moderation/gov-claim-presentation" export function useGovClaimListInfinite(params: GovClaimListQuery) { - return useInfiniteQuery({ - queryKey: queryKeys.govClaims.list(params), - queryFn: ({ pageParam }) => - api.listGovClaims({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) + return useInfiniteQuery( + infiniteListOptions(queryKeys.govClaims.list(params), params, (input) => + api.listGovClaims(input), + ), + ) } export function useGovClaim(id: string | null) { @@ -39,11 +40,8 @@ export function useGovClaim(id: string | null) { }) } -function invalidateGovClaims(qc: ReturnType, id: string) { - return Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.govClaims.detail(id) }), - qc.invalidateQueries({ queryKey: queryKeys.govClaims.all }), - ]) +function invalidateGovClaims(qc: QueryClient, id: string) { + return invalidateKeys(qc, [queryKeys.govClaims.detail(id), queryKeys.govClaims.all]) } export function useVerifyGovClaimCheck() { diff --git a/apps/admin/src/features/moderation/use-moderation.ts b/apps/admin/src/features/moderation/use-moderation.ts index b357538..ead2eef 100644 --- a/apps/admin/src/features/moderation/use-moderation.ts +++ b/apps/admin/src/features/moderation/use-moderation.ts @@ -1,6 +1,12 @@ "use client" -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query" import type { ApproveModerationRequest, AppealModerationRequest, @@ -13,7 +19,8 @@ import type { } from "@civfix/shared" import { api } from "@/lib/api" -import { queryKeys } from "@/lib/query" +import { infiniteListOptions } from "@/lib/infinite" +import { invalidateKeys, queryKeys } from "@/lib/query" export function useModerationList(params: ModerationListQuery) { return useQuery({ @@ -23,16 +30,11 @@ export function useModerationList(params: ModerationListQuery) { } export function useModerationListInfinite(params: ModerationListQuery) { - return useInfiniteQuery({ - queryKey: queryKeys.moderation.list(params), - queryFn: ({ pageParam }) => - api.listModeration({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) + return useInfiniteQuery( + infiniteListOptions(queryKeys.moderation.list(params), params, (input) => + api.listModeration(input), + ), + ) } export function useModerationItem(id: string | null) { @@ -45,15 +47,15 @@ export function useModerationItem(id: string | null) { // A decision can take down a report, a chat message, media or a whole cleanup (and its signup page) and // changes the subject's strikes and status, so every section that shows those refreshes too. -function invalidateModeration(qc: ReturnType, id?: string) { - return Promise.all([ - id ? qc.invalidateQueries({ queryKey: queryKeys.moderation.detail(id) }) : null, - qc.invalidateQueries({ queryKey: queryKeys.moderation.all }), - qc.invalidateQueries({ queryKey: queryKeys.reports.all }), - qc.invalidateQueries({ queryKey: queryKeys.events.all }), - qc.invalidateQueries({ queryKey: queryKeys.pages.all }), - qc.invalidateQueries({ queryKey: queryKeys.users.all }), - qc.invalidateQueries({ queryKey: queryKeys.home.all }), +function invalidateModeration(qc: QueryClient, id?: string) { + return invalidateKeys(qc, [ + id ? queryKeys.moderation.detail(id) : null, + queryKeys.moderation.all, + queryKeys.reports.all, + queryKeys.events.all, + queryKeys.pages.all, + queryKeys.users.all, + queryKeys.home.all, ]) } diff --git a/apps/admin/src/features/orgs/evidence-list.tsx b/apps/admin/src/features/orgs/evidence-list.tsx index db6ce26..867c292 100644 --- a/apps/admin/src/features/orgs/evidence-list.tsx +++ b/apps/admin/src/features/orgs/evidence-list.tsx @@ -6,16 +6,10 @@ import { Icons } from "@/components/icons" import { LoadingState, ErrorState } from "@/components/shared/data-states" import { EmptyState } from "@/components/shared/page-primitives" import { formatDateTime } from "@/lib/dates" -import { SHORT_ID_LENGTH } from "@/lib/display" +import { shortId } from "@/lib/display" import { evidenceUrlRemainingMs, isEvidenceUrlExpired } from "@/features/orgs/evidence-cache" import { useOrgVerificationDocument } from "@/features/orgs/use-orgs" -const UUID_HYPHENS = /-/g - -function shortMediaId(id: string): string { - return id.replace(UUID_HYPHENS, "").slice(0, SHORT_ID_LENGTH) -} - function EvidenceViewer({ mediaId, index }: { mediaId: string; index: number }) { const q = useOrgVerificationDocument(mediaId) const expiresAt = q.data?.expiresAt ?? null @@ -61,7 +55,6 @@ function EvidenceViewer({ mediaId, index }: { mediaId: string; index: number }) return (
{media.kind === "image" ? ( - // eslint-disable-next-line @next/next/no-img-element {`Evidence ) : null} @@ -113,7 +106,7 @@ export function EvidenceList({ mediaIds }: { mediaIds: string[] }) { Document {index + 1} - {shortMediaId(mediaId)} + {shortId(mediaId)} {open && ( diff --git a/apps/admin/src/features/orgs/members-panel.test.tsx b/apps/admin/src/features/orgs/members-panel.test.tsx index bad5177..0763d94 100644 --- a/apps/admin/src/features/orgs/members-panel.test.tsx +++ b/apps/admin/src/features/orgs/members-panel.test.tsx @@ -9,7 +9,7 @@ import { renderWithQuery } from "@/test/render" import { DialogHost } from "@/components/shared/dialog" import { MembersPanel } from "@/features/orgs/members-panel" import { OWNER, SAM, makeOrg, member } from "@/features/orgs/test-fixtures" -import { useAdminOrg } from "@/features/orgs/use-orgs" +import { useOrg } from "@/features/orgs/use-orgs" import { useUiStore } from "@/store/ui-store" vi.mock("@/lib/api", async (importOriginal) => { @@ -97,7 +97,7 @@ describe("MembersPanel row actions", () => { return { ok: true } }) function OrgDetailObserver() { - useAdminOrg(org.id) + useOrg(org.id) return null } renderWithQuery( diff --git a/apps/admin/src/features/orgs/members-panel.tsx b/apps/admin/src/features/orgs/members-panel.tsx index aba4e5e..1f7183c 100644 --- a/apps/admin/src/features/orgs/members-panel.tsx +++ b/apps/admin/src/features/orgs/members-panel.tsx @@ -5,10 +5,12 @@ import type { AdminOrgDTO, AdminOrgMemberDTO, OrganizationMemberRole } from "@ci import { Icons } from "@/components/icons" import { LoadingState, ErrorState } from "@/components/shared/data-states" -import { confirmDialog, promptDialog } from "@/components/shared/dialog" +import { LoadMoreButton } from "@/components/shared/section-list" +import { confirmDialog, promptReason } from "@/components/shared/dialog" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" import { EmptyState } from "@/components/shared/page-primitives" import { formatDate } from "@/lib/dates" +import { flatPages } from "@/lib/infinite" import { ORG_ROLE_LABEL, ORG_ROLE_PILL, @@ -19,7 +21,7 @@ import { import { FieldError, ReasonField, fieldErrorId } from "@/features/orgs/org-form-fields" import { useAddOrgMember, - useOrgMembersInfinite, + useOrgMemberListInfinite, useRemoveOrgMember, useSetOrgMemberRole, } from "@/features/orgs/use-orgs" @@ -28,14 +30,13 @@ import { useRowMenu, type RowMenu } from "@/features/orgs/use-row-menu" import { useNav } from "@/store/ui-store" const MEMBER_AVATAR_SIZE = 32 -const REASON_LABEL = "Reason (required)" const CURRENT_OWNER_FALLBACK = "the current owner" const PERSON_FIELD_ID = "org-add-person" const PERSON_LABEL_ID = "org-add-person-label" function useRoster(org: AdminOrgDTO) { - const q = useOrgMembersInfinite(org.id) - const members = React.useMemo(() => q.data?.pages.flatMap((p) => p.items) ?? [], [q.data]) + const q = useOrgMemberListInfinite(org.id) + const members = React.useMemo(() => flatPages(q.data), [q.data]) const ownerName = members.find((m) => m.role === "owner")?.user.name ?? org.owner?.name ?? null return { q, members, ownerName } } @@ -61,16 +62,7 @@ function RosterBody({ org, roster: { q, members, ownerName } }: { org: AdminOrgD {members.map((m) => ( ))} - {q.hasNextPage && ( - - )} + ) } @@ -144,14 +136,11 @@ function useMemberActions(org: AdminOrgDTO, member: AdminOrgMemberDTO, ownerName const askReason = async (prompt: ReasonPrompt): Promise => { prompting.current = true - let reason: string | null try { - reason = await promptDialog({ ...prompt, label: REASON_LABEL, required: true }) + return await promptReason(prompt) } finally { prompting.current = false } - const trimmed = reason?.trim() ?? "" - return trimmed === "" ? null : trimmed } const changeRole = async (to: OrganizationMemberRole) => { diff --git a/apps/admin/src/features/orgs/org-detail.tsx b/apps/admin/src/features/orgs/org-detail.tsx index 85d1a07..f237f28 100644 --- a/apps/admin/src/features/orgs/org-detail.tsx +++ b/apps/admin/src/features/orgs/org-detail.tsx @@ -5,7 +5,7 @@ import type { AdminOrgDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { LoadingState, ErrorState } from "@/components/shared/data-states" -import { promptDialog } from "@/components/shared/dialog" +import { promptReason } from "@/components/shared/dialog" import { formatDateTime } from "@/lib/dates" import { orgStatusView } from "@/lib/org-status" import { MembersPanel } from "@/features/orgs/members-panel" @@ -16,7 +16,7 @@ import { menuFocusIndex } from "@/features/orgs/org-members" import { publicOrgUrl } from "@/features/orgs/org-slug" import { ORG_KIND_LABEL } from "@/features/orgs/org-verification" import { ProfilePanel } from "@/features/orgs/profile-panel" -import { useAdminOrg, useSetOrgSuspended } from "@/features/orgs/use-orgs" +import { useOrg, useSetOrgSuspended } from "@/features/orgs/use-orgs" import { VerificationPanel } from "@/features/orgs/verification-panel" const TAB_LABEL: Record = { @@ -37,24 +37,22 @@ function SuspendButton({ org }: { org: AdminOrgDTO }) { busy.current = true let reason: string | null try { - reason = await promptDialog({ + reason = await promptReason({ 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.", - label: "Reason (required)", placeholder: suspended ? "Resolved after the org replaced its contact…" : "Repeated broadcast abuse reports; pending review with the org…", confirmLabel: suspended ? "Restore organization" : "Suspend organization", - required: true, danger: !suspended, }) } finally { busy.current = false } - if (reason === null || reason.trim() === "") return - suspend.mutate({ id: org.id, suspended: !suspended, reason: reason.trim() }) + if (reason === null) return + suspend.mutate({ id: org.id, suspended: !suspended, reason }) } return ( @@ -228,7 +226,7 @@ export function OrgDetail({ tab: OrgDetailTab onTab: (t: OrgDetailTab) => void }) { - const q = useAdminOrg(orgId) + const q = useOrg(orgId) const tabIds = React.useId() if (q.isLoading) return diff --git a/apps/admin/src/features/orgs/org-events-panel.tsx b/apps/admin/src/features/orgs/org-events-panel.tsx index d162748..d8117e9 100644 --- a/apps/admin/src/features/orgs/org-events-panel.tsx +++ b/apps/admin/src/features/orgs/org-events-panel.tsx @@ -5,11 +5,13 @@ import type { AdminOrgDTO, AdminOrgEventListResponse, AdminOrgEventWhen } from " import { Icons } from "@/components/icons" import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { LoadMoreButton } from "@/components/shared/section-list" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" import { EmptyState, FilterChips } from "@/components/shared/page-primitives" import { eventKindView } from "@/lib/event-kind" import { eventStatusView } from "@/lib/event-status" -import { useOrgEventsInfinite } from "@/features/orgs/use-orgs" +import { flatPages } from "@/lib/infinite" +import { useOrgEventListInfinite } from "@/features/orgs/use-orgs" import { useNav } from "@/store/ui-store" const WHEN_OPTIONS: { value: AdminOrgEventWhen; label: string }[] = [ @@ -73,9 +75,9 @@ function OrgEventRow({ item, onOpen }: { item: OrgEventItem; onOpen: () => void export function OrgEventsPanel({ org }: { org: AdminOrgDTO }) { const [when, setWhen] = React.useState("upcoming") - const q = useOrgEventsInfinite(org.id, when) + const q = useOrgEventListInfinite(org.id, when) const nav = useNav() - const items = React.useMemo(() => q.data?.pages.flatMap((p) => p.items) ?? [], [q.data]) + const items = React.useMemo(() => flatPages(q.data), [q.data]) return (
@@ -116,16 +118,7 @@ export function OrgEventsPanel({ org }: { org: AdminOrgDTO }) { {items.map((item) => ( nav("events", item.id)} /> ))} - {q.hasNextPage && ( - - )} + )}
diff --git a/apps/admin/src/features/orgs/org-form-fields.tsx b/apps/admin/src/features/orgs/org-form-fields.tsx index 2853c5c..92c303a 100644 --- a/apps/admin/src/features/orgs/org-form-fields.tsx +++ b/apps/admin/src/features/orgs/org-form-fields.tsx @@ -54,7 +54,7 @@ export function fieldHintId(fieldId: string): string { * The `aria-invalid` / `aria-describedby` pair for a control: described by its error when there is * one, else by whichever hints are on screen, so no reference ever points at an unrendered node. */ -export function fieldA11y( +function fieldA11y( fieldId: string, error: string | null | undefined, hintIds: readonly string[] = [], @@ -170,7 +170,6 @@ function LogoField({
{draft.logoPreviewUrl ? ( - // eslint-disable-next-line @next/next/no-img-element ) : ( {monogram(draft.name)} diff --git a/apps/admin/src/features/orgs/org-list.tsx b/apps/admin/src/features/orgs/org-list.tsx index 48e5666..725e7f7 100644 --- a/apps/admin/src/features/orgs/org-list.tsx +++ b/apps/admin/src/features/orgs/org-list.tsx @@ -1,28 +1,29 @@ "use client" +import * as React from "react" import type { AdminOrgDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" import { EmptyState } from "@/components/shared/page-primitives" -import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { ListStates, LoadMoreButton } from "@/components/shared/section-list" import { formatDate } from "@/lib/dates" import { orgStatusView } from "@/lib/org-status" import { OrgLogo } from "@/features/orgs/org-logo" -import type { useOrgsInfinite } from "@/features/orgs/use-orgs" +import type { useOrgListInfinite } from "@/features/orgs/use-orgs" function plural(count: number, one: string, many: string): string { return `${count.toLocaleString()} ${count === 1 ? one : many}` } -function OrgRow({ +const OrgRow = React.memo(function OrgRow({ org, selected, - onClick, + onSelect, }: { org: AdminOrgDTO selected: boolean - onClick: () => void + onSelect: (id: string) => void }) { const view = orgStatusView(org.verifiedStatus) const suspended = !!org.suspendedAt @@ -33,11 +34,11 @@ function OrgRow({ role="button" tabIndex={0} aria-current={selected ? "true" : undefined} - onClick={onClick} + onClick={() => onSelect(org.id)} onKeyDown={(e) => { if (!isKeyboardActivationKey(e.key)) return e.preventDefault() - onClick() + onSelect(org.id) }} >
@@ -72,7 +73,7 @@ function OrgRow({
) -} +}) export function OrgList({ listQuery, @@ -81,18 +82,14 @@ export function OrgList({ selId, onSelect, }: { - listQuery: ReturnType + listQuery: ReturnType items: AdminOrgDTO[] pendingView: boolean selId: string | null onSelect: (id: string) => void }) { - if (listQuery.isLoading) return - if (listQuery.isError) { - return listQuery.refetch()} /> - } return ( - <> + {items.length === 0 ? ( ) : ( items.map((o) => ( - onSelect(o.id)} /> + )) )} - {listQuery.hasNextPage && ( - - )} - + + ) } diff --git a/apps/admin/src/features/orgs/org-logo.tsx b/apps/admin/src/features/orgs/org-logo.tsx index 9bea2ec..05a7936 100644 --- a/apps/admin/src/features/orgs/org-logo.tsx +++ b/apps/admin/src/features/orgs/org-logo.tsx @@ -3,7 +3,7 @@ import type * as React from "react" import { monogram, type AdminOrgDTO } from "@civfix/shared" -export const ORG_LOGO_ROW_SIZE = 32 +const ORG_LOGO_ROW_SIZE = 32 export const ORG_LOGO_HEADER_SIZE = 36 const MONOGRAM_FONT_RATIO = 0.42 @@ -16,7 +16,6 @@ export function OrgLogo({ }) { const style: React.CSSProperties = { width: size, height: size } if (org.logoUrl) { - // eslint-disable-next-line @next/next/no-img-element return } return ( diff --git a/apps/admin/src/features/orgs/orgs-filters.ts b/apps/admin/src/features/orgs/orgs-filters.ts index 33259e7..44bb22c 100644 --- a/apps/admin/src/features/orgs/orgs-filters.ts +++ b/apps/admin/src/features/orgs/orgs-filters.ts @@ -55,7 +55,8 @@ export function orgFilterCount( return counts.verified case "suspended": return counts.suspended - default: + case "rejected": + case "unverified": return undefined } } diff --git a/apps/admin/src/features/orgs/orgs-page.tsx b/apps/admin/src/features/orgs/orgs-page.tsx index bac84c1..78ee257 100644 --- a/apps/admin/src/features/orgs/orgs-page.tsx +++ b/apps/admin/src/features/orgs/orgs-page.tsx @@ -4,7 +4,10 @@ import * as React from "react" import { Icons } from "@/components/icons" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" +import { ListCard, SearchBox } from "@/components/shared/section-list" import { useDebounced } from "@/hooks/use-debounced" +import { idOf, useSelection } from "@/hooks/use-selection" +import { flatPages } from "@/lib/infinite" import { CreateOrgPanel } from "@/features/orgs/create-org-panel" import { OrgDetail } from "@/features/orgs/org-detail" import { OrgList } from "@/features/orgs/org-list" @@ -17,50 +20,13 @@ import { type OrgFilter, } from "@/features/orgs/orgs-filters" import { parseOrgFocus, type OrgDetailTab } from "@/features/orgs/org-focus" -import { useOrgsInfinite } from "@/features/orgs/use-orgs" +import { useOrgListInfinite } from "@/features/orgs/use-orgs" import type { SectionPageProps } from "@/components/shell/page-registry" -/** - * Only the first load picks an org on the operator's behalf; a deep-linked or just-created org is - * pinned like any other pick. A pick that a filter or search leaves out stays open (the detail reads it - * by id); a pick that drops out of the same list after a refetch (a verification decision under the - * Pending chip) clears, so the pane never jumps to another org's actions. Each decision waits for data - * fetched for the current params: a cached page that is refetching may not have the org created since. - */ -function useListSelection({ - selId, - setSelId, - initialAutoPick, - listQuery, - items, - listKey, -}: { - selId: string | null - setSelId: (id: string | null) => void - initialAutoPick: boolean - listQuery: ReturnType - items: { id: string }[] - listKey: string -}) { - const [autoPick, setAutoPick] = React.useState(initialAutoPick) - const seenIn = React.useRef<{ id: string; list: string } | null>(null) - React.useEffect(() => { - if (!listQuery.isSuccess || listQuery.isFetching) return - if (selId === null) { - if (autoPick && items.length) setSelId(items[0]!.id) - return - } - setAutoPick(false) - if (items.some((o) => o.id === selId)) seenIn.current = { id: selId, list: listKey } - else if (seenIn.current?.id === selId && seenIn.current.list === listKey) setSelId(null) - }, [listQuery.isSuccess, listQuery.isFetching, items, selId, listKey, autoPick, setSelId]) -} - export function OrgsPage({ focusId }: SectionPageProps) { const focus = React.useMemo(() => parseOrgFocus(focusId), [focusId]) const [filter, setFilter] = React.useState("all") const [query, setQuery] = React.useState("") - const [selId, setSelId] = React.useState(focus.id) const [tab, setTab] = React.useState(focus.tab ?? "profile") const [creating, setCreating] = React.useState(false) @@ -69,9 +35,9 @@ export function OrgsPage({ focusId }: SectionPageProps) { () => orgListParams(filter, debouncedQuery), [filter, debouncedQuery], ) - const listQuery = useOrgsInfinite(listParams) + const listQuery = useOrgListInfinite(listParams) const items = React.useMemo( - () => listQuery.data?.pages.flatMap((p) => p.items) ?? [], + () => flatPages(listQuery.data), [listQuery.data], ) const counts = listQuery.data?.pages[0]?.counts @@ -79,18 +45,20 @@ export function OrgsPage({ focusId }: SectionPageProps) { const filterTotal = orgFilterCount(counts, filter) const pendingView = filter === "pending" - React.useEffect(() => { - if (focus.id) setSelId(focus.id) - if (focus.tab) setTab(focus.tab) - }, [focus]) - useListSelection({ - selId, - setSelId, - initialAutoPick: focus.id === null, - listQuery, + // A just-created org is pinned like any other pick. The focus also carries a tab, so a deep link to + // the same org with another tab must still re-select it. + const { selectedId: selId, setSelectedId: setSelId } = useSelection({ + focusId: focus.id, + syncFocus: false, + list: listQuery, items, + getId: idOf, listKey: JSON.stringify(listParams), }) + React.useEffect(() => { + if (focus.id) setSelId(focus.id) + if (focus.tab) setTab(focus.tab) + }, [focus, setSelId]) const pickFilter = (next: string) => { if (!isOrgFilter(next)) return @@ -127,38 +95,32 @@ export function OrgsPage({ focusId }: SectionPageProps) { onChange={pickFilter} />
-
- - setQuery(e.target.value)} - /> -
+
-
-
-

{pendingView ? "Verification queue" : "Organizations"}

-
- + {items.length} {filterTotal !== undefined ? ` of ${filterTotal}` : ""} - -
-
- -
-
+ + } + > + +
{selId ? ( diff --git a/apps/admin/src/features/orgs/profile-panel.tsx b/apps/admin/src/features/orgs/profile-panel.tsx index 1c086fe..d4ceea4 100644 --- a/apps/admin/src/features/orgs/profile-panel.tsx +++ b/apps/admin/src/features/orgs/profile-panel.tsx @@ -9,7 +9,7 @@ import { } from "@civfix/shared" import { Icons } from "@/components/icons" -import { promptDialog } from "@/components/shared/dialog" +import { promptReason } from "@/components/shared/dialog" import { formatDate, formatDateTime } from "@/lib/dates" import { EMPTY_VALUE } from "@/lib/empty-value" import { @@ -163,21 +163,19 @@ function useProfileEditor(org: AdminOrgDTO, onDone: () => void) { busy.current = true let reason: string | null try { - reason = await promptDialog({ + reason = await promptReason({ title: `Save changes to ${org.name}?`, body: slugChanged ? `The slug changes from /${baseline.slug} to /${nextSlug}. Existing links, QR codes and signup pages that use the old slug stop working. The reason is written to the audit log.` : "The change is visible on the public page immediately. The reason is written to the audit log.", - label: "Reason (required)", placeholder: "Corrected the website at the org's request…", confirmLabel: "Save changes", - required: true, danger: slugChanged, }) } finally { busy.current = false } - if (reason === null || reason.trim() === "") return + if (reason === null) return const body = buildUpdateRequest(baseline, draft, reason) if (!body) { onDone() diff --git a/apps/admin/src/features/orgs/use-orgs.ts b/apps/admin/src/features/orgs/use-orgs.ts index c60b415..649ae41 100644 --- a/apps/admin/src/features/orgs/use-orgs.ts +++ b/apps/admin/src/features/orgs/use-orgs.ts @@ -1,15 +1,18 @@ "use client" -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query" import type { AdminAddOrgMemberRequest, AdminCreateOrgRequest, AdminGetMediaResponse, - AdminOrgEventListResponse, AdminOrgEventWhen, AdminOrgListQuery, - AdminOrgListResponse, - AdminOrgMemberListResponse, AdminRemoveOrgMemberRequest, AdminSetOrgMemberRoleRequest, AdminSetOrgSuspendedRequest, @@ -20,7 +23,8 @@ import type { import { api } from "@/lib/api" import { errorMessage } from "@/lib/error-messages" -import { queryKeys } from "@/lib/query" +import { infiniteListOptions } from "@/lib/infinite" +import { invalidateKeys, queryKeys } from "@/lib/query" import { EVIDENCE_URL_MAX_CACHE_MS, evidenceStaleTime } from "@/features/orgs/evidence-cache" import { fieldErrorsFromError, @@ -32,20 +36,13 @@ import { ORG_ROLE_LABEL } from "@/features/orgs/org-members" import { ORG_KIND_LABEL } from "@/features/orgs/org-verification" /** Page one carries the chip `counts`. */ -export function useOrgsInfinite(params: AdminOrgListQuery) { - return useInfiniteQuery({ - queryKey: queryKeys.orgs.list(params), - queryFn: ({ pageParam }) => - api.adminListOrgs({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) +export function useOrgListInfinite(params: AdminOrgListQuery) { + return useInfiniteQuery( + infiniteListOptions(queryKeys.orgs.list(params), params, (input) => api.adminListOrgs(input)), + ) } -export function useAdminOrg(id: string | null) { +export function useOrg(id: string | null) { return useQuery({ queryKey: queryKeys.orgs.detail(id ?? ""), queryFn: () => api.adminGetOrg({ id: id as string }), @@ -53,31 +50,24 @@ export function useAdminOrg(id: string | null) { }) } -export function useOrgMembersInfinite(id: string | null) { - return useInfiniteQuery({ - queryKey: queryKeys.orgs.members(id ?? ""), - queryFn: ({ pageParam }) => - api.adminListOrgMembers({ - id: id as string, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, +export function useOrgMemberListInfinite(id: string | null) { + return useInfiniteQuery({ + ...infiniteListOptions( + queryKeys.orgs.members(id ?? ""), + { id: id as string }, + (input) => api.adminListOrgMembers(input), + ), enabled: !!id, }) } -export function useOrgEventsInfinite(id: string | null, when: AdminOrgEventWhen) { - return useInfiniteQuery({ - queryKey: queryKeys.orgs.events(id ?? "", { when }), - queryFn: ({ pageParam }) => - api.adminListOrgEvents({ - id: id as string, - when, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, +export function useOrgEventListInfinite(id: string | null, when: AdminOrgEventWhen) { + return useInfiniteQuery({ + ...infiniteListOptions( + queryKeys.orgs.events(id ?? "", { when }), + { id: id as string, when }, + (input) => api.adminListOrgEvents(input), + ), enabled: !!id, }) } @@ -92,34 +82,26 @@ export function useOrgVerificationDocument(mediaId: string | null) { }) } -type Qc = ReturnType - -function invalidateOrgLists(qc: Qc) { - return Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.orgs.all }), - qc.invalidateQueries({ queryKey: queryKeys.audit.all }), - ]) -} - -function invalidateOrg(qc: Qc, id: string) { - return Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.orgs.detail(id) }), - invalidateOrgLists(qc), +function invalidateOrg(qc: QueryClient, id?: string) { + return invalidateKeys(qc, [ + id ? queryKeys.orgs.detail(id) : null, + queryKeys.orgs.all, + queryKeys.audit.all, ]) } -function storeOrgAndInvalidate(qc: Qc, id: string, org: GetAdminOrgResponse) { +function storeOrgAndInvalidate(qc: QueryClient, id: string, org: GetAdminOrgResponse) { qc.setQueryData(queryKeys.orgs.detail(id), org) return invalidateOrg(qc, id) } -function invalidateOrgMembers(qc: Qc, id: string) { - return Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.orgs.members(id) }), - qc.invalidateQueries({ queryKey: queryKeys.orgs.detail(id) }), - qc.invalidateQueries({ queryKey: queryKeys.orgs.all }), - qc.invalidateQueries({ queryKey: queryKeys.users.all }), - qc.invalidateQueries({ queryKey: queryKeys.audit.all }), +function invalidateOrgMembers(qc: QueryClient, id: string) { + return invalidateKeys(qc, [ + queryKeys.orgs.members(id), + queryKeys.orgs.detail(id), + queryKeys.orgs.all, + queryKeys.users.all, + queryKeys.audit.all, ]) } @@ -152,7 +134,7 @@ export function useCreateOrg() { mutationFn: (input: AdminCreateOrgRequest) => api.adminCreateOrg(input), onSuccess: (org) => { qc.setQueryData(queryKeys.orgs.detail(org.id), org) - return invalidateOrgLists(qc) + return invalidateOrg(qc) }, meta: { errorMessage: (error: unknown, request: AdminCreateOrgRequest) => diff --git a/apps/admin/src/features/orgs/use-row-menu.ts b/apps/admin/src/features/orgs/use-row-menu.ts index eab6419..9eff7c1 100644 --- a/apps/admin/src/features/orgs/use-row-menu.ts +++ b/apps/admin/src/features/orgs/use-row-menu.ts @@ -16,7 +16,7 @@ export interface RowMenuPosition { right: number } -function menuPositionFor(rect: DOMRect): RowMenuPosition { +function menuPosition(rect: DOMRect): RowMenuPosition { const right = Math.max(MENU_VIEWPORT_MARGIN_PX, window.innerWidth - rect.right) const flipUp = rect.bottom + MENU_EST_HEIGHT_PX > window.innerHeight return flipUp @@ -45,7 +45,7 @@ export function useRowMenu() { const openMenu = () => { const rect = triggerRef.current?.getBoundingClientRect() if (!rect) return - setPosition(menuPositionFor(rect)) + setPosition(menuPosition(rect)) } const close = React.useCallback((returnFocus: boolean) => { setPosition(null) diff --git a/apps/admin/src/features/orgs/user-picker.tsx b/apps/admin/src/features/orgs/user-picker.tsx index 30797bf..a55dd74 100644 --- a/apps/admin/src/features/orgs/user-picker.tsx +++ b/apps/admin/src/features/orgs/user-picker.tsx @@ -33,7 +33,6 @@ export interface PickedUser { export function PickedUserAvatar({ user, size = PICKED_AVATAR_SIZE }: { user: PickedUser; size?: number }) { const style: React.CSSProperties = { width: size, height: size, fontSize: Math.round(size * AVATAR_FONT_RATIO) } if (user.avatarUrl) { - // eslint-disable-next-line @next/next/no-img-element return } return ( diff --git a/apps/admin/src/features/orgs/verification-panel.tsx b/apps/admin/src/features/orgs/verification-panel.tsx index ff6d5e9..172d3f2 100644 --- a/apps/admin/src/features/orgs/verification-panel.tsx +++ b/apps/admin/src/features/orgs/verification-panel.tsx @@ -8,7 +8,7 @@ import { import { Icons } from "@/components/icons" import { LoadingState, ErrorState } from "@/components/shared/data-states" -import { confirmDialog, promptDialog } from "@/components/shared/dialog" +import { confirmDialog, promptReason } from "@/components/shared/dialog" import { formatDate, formatDateTime } from "@/lib/dates" import { EMPTY_VALUE } from "@/lib/empty-value" import { EvidenceList } from "@/features/orgs/evidence-list" @@ -20,13 +20,13 @@ import { kindLabel, } from "@/features/orgs/org-facts" import { ORG_KIND_LABEL, canDecideVerification } from "@/features/orgs/org-verification" -import { useAdminOrg, useDecideOrgVerification } from "@/features/orgs/use-orgs" +import { useOrg, useDecideOrgVerification } from "@/features/orgs/use-orgs" import { useNav } from "@/store/ui-store" type OrgVerification = NonNullable export function VerificationPanel({ orgId }: { orgId: string }) { - const q = useAdminOrg(orgId) + const q = useOrg(orgId) if (q.isLoading) return if (q.isError && !q.data) { @@ -61,7 +61,6 @@ function OrgSummary({ org }: { org: AdminOrgDTO }) {
{org.logoUrl ? ( - // eslint-disable-next-line @next/next/no-img-element ) : ( @@ -168,17 +167,15 @@ function DecisionSection({ org }: { org: AdminOrgDTO }) { } const onReject = async () => { - const reason = await promptDialog({ + const reason = await promptReason({ title: `Reject ${org.name}?`, body: "The applicant sees the reason. It is written to the audit log.", - label: "Reason (required)", placeholder: "The uploaded determination letter does not match the submitted EIN…", confirmLabel: "Reject verification", - required: true, danger: true, }) - if (reason === null || reason.trim() === "") return - decide.mutate({ id: org.id, decision: "rejected", reason: reason.trim() }) + if (reason === null) return + decide.mutate({ id: org.id, decision: "rejected", reason }) } if (!canDecideVerification(org)) { diff --git a/apps/admin/src/features/pages/page-detail.tsx b/apps/admin/src/features/pages/page-detail.tsx index 7063a2e..3872335 100644 --- a/apps/admin/src/features/pages/page-detail.tsx +++ b/apps/admin/src/features/pages/page-detail.tsx @@ -3,7 +3,7 @@ import type { AdminEventPageListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" -import { promptDialog } from "@/components/shared/dialog" +import { promptReason } from "@/components/shared/dialog" import { formatDateTime } from "@/lib/dates" import { EMPTY_VALUE } from "@/lib/empty-value" import { pageStatusView, VISIBILITY_LABEL } from "@/features/pages/page-labels" @@ -102,30 +102,26 @@ function PageModerationActions({ flag.mutate({ id: item.cleanupId, flagged: false }) return } - const reason = await promptDialog({ + const reason = await promptReason({ title: `Flag “${item.title}”?`, body: "Flagging marks the page for review without taking it off the public web. The reason is written to the audit log.", - label: "Reason (required)", placeholder: "Fundraising claims that do not match the linked organization…", confirmLabel: "Flag page", - required: true, }) - if (reason === null || reason.trim() === "") return - flag.mutate({ id: item.cleanupId, flagged: true, reason: reason.trim() }) + if (reason === null) return + flag.mutate({ id: item.cleanupId, flagged: true, reason }) } const onUnpublish = async () => { - const reason = await promptDialog({ + const reason = await promptReason({ title: `Unpublish “${item.title}”?`, body: "The public page returns a 404 immediately. The event, its roster and every registration are untouched. The reason is written to the audit log.", - label: "Reason (required)", placeholder: "Page impersonates a city agency…", confirmLabel: "Unpublish page", - required: true, danger: true, }) - if (reason === null || reason.trim() === "") return - unpublish.mutate({ id: item.cleanupId, reason: reason.trim() }) + if (reason === null) return + unpublish.mutate({ id: item.cleanupId, reason }) } return ( diff --git a/apps/admin/src/features/pages/page-list-pane.tsx b/apps/admin/src/features/pages/page-list-pane.tsx index 585aeb5..62547fd 100644 --- a/apps/admin/src/features/pages/page-list-pane.tsx +++ b/apps/admin/src/features/pages/page-list-pane.tsx @@ -4,11 +4,9 @@ import type { AdminEventPageListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" -import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { ListCard, ListStates, LoadMoreButton } from "@/components/shared/section-list" import { PageRow } from "@/features/pages/page-row" -import type { useEventPagesInfinite } from "@/features/pages/use-pages" - -type PageListQuery = ReturnType +import type { useEventPageListInfinite } from "@/features/pages/use-pages" export function PageListPane({ listQuery, @@ -16,59 +14,42 @@ export function PageListPane({ selectedId, onSelect, }: { - listQuery: PageListQuery + listQuery: ReturnType items: AdminEventPageListItemDTO[] selectedId: string | null onSelect: (id: string) => void }) { return ( -
-
-

Pages

-
- {listQuery.isSuccess && ( - + {items.length} {listQuery.hasNextPage ? "+" : ""} - - )} -
-
- {listQuery.isLoading ? ( - - ) : listQuery.isError ? ( - listQuery.refetch()} /> - ) : ( - <> - {items.length === 0 ? ( - } - /> - ) : ( - items.map((page) => ( - onSelect(page.cleanupId)} - /> - )) - )} - {listQuery.hasNextPage && ( - - )} + ) : undefined + } + > + + {items.length === 0 ? ( + } + /> + ) : ( + items.map((page) => ( + + )) )} -
-
+ + + ) } diff --git a/apps/admin/src/features/pages/page-preview.tsx b/apps/admin/src/features/pages/page-preview.tsx index 18a2262..a9a9c89 100644 --- a/apps/admin/src/features/pages/page-preview.tsx +++ b/apps/admin/src/features/pages/page-preview.tsx @@ -7,7 +7,7 @@ import { LoadingState, ErrorState } from "@/components/shared/data-states" import { EmptyState } from "@/components/shared/page-primitives" import { pageBlockViews, type PageBlockView } from "@/features/pages/page-blocks" import { publicPagePath } from "@/features/pages/page-path" -import { useAdminEventPage } from "@/features/pages/use-pages" +import { useEventPage } from "@/features/pages/use-pages" function PageBlockCard({ block }: { block: PageBlockView }) { return ( @@ -19,7 +19,6 @@ function PageBlockCard({ block }: { block: PageBlockView }) { {block.lines.length > 0 && (
{block.lines.map((line, index) => ( - // eslint-disable-next-line react/no-array-index-key

{line}

))}
@@ -38,7 +37,7 @@ function PageBlockCard({ block }: { block: PageBlockView }) { } export function PagePreview({ cleanupId, title }: { cleanupId: string; title: string }) { - const pageQuery = useAdminEventPage(cleanupId) + const pageQuery = useEventPage(cleanupId) const blocks = React.useMemo(() => pageBlockViews(pageQuery.data?.blocks ?? []), [pageQuery.data]) if (pageQuery.isLoading) return @@ -57,7 +56,6 @@ export function PagePreview({ cleanupId, title }: { cleanupId: string; title: st return (
{page.coverUrl && ( - // eslint-disable-next-line @next/next/no-img-element )}
diff --git a/apps/admin/src/features/pages/page-row.tsx b/apps/admin/src/features/pages/page-row.tsx index 301e82b..6ff9df6 100644 --- a/apps/admin/src/features/pages/page-row.tsx +++ b/apps/admin/src/features/pages/page-row.tsx @@ -1,5 +1,6 @@ "use client" +import * as React from "react" import type { AdminEventPageListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" @@ -8,14 +9,14 @@ import { formatDateTime } from "@/lib/dates" import { pageStatusView, VISIBILITY_LABEL } from "@/features/pages/page-labels" import { publicPagePath } from "@/features/pages/page-path" -export function PageRow({ +export const PageRow = React.memo(function PageRow({ item, selected, - onClick, + onSelect, }: { item: AdminEventPageListItemDTO selected: boolean - onClick: () => void + onSelect: (id: string) => void }) { const view = pageStatusView(item.status) return ( @@ -24,11 +25,11 @@ export function PageRow({ role="button" tabIndex={0} aria-current={selected ? "true" : undefined} - onClick={onClick} + onClick={() => onSelect(item.cleanupId)} onKeyDown={(e) => { if (!isKeyboardActivationKey(e.key)) return e.preventDefault() - onClick() + onSelect(item.cleanupId) }} >
@@ -70,4 +71,4 @@ export function PageRow({
) -} +}) diff --git a/apps/admin/src/features/pages/pages-page.tsx b/apps/admin/src/features/pages/pages-page.tsx index 4f3fdf5..622df7b 100644 --- a/apps/admin/src/features/pages/pages-page.tsx +++ b/apps/admin/src/features/pages/pages-page.tsx @@ -5,9 +5,11 @@ import type { AdminEventPageListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" +import { SearchBox } from "@/components/shared/section-list" import { LoadingState, ErrorState } from "@/components/shared/data-states" import { useDebounced } from "@/hooks/use-debounced" -import { SEARCH_DEBOUNCE_MS } from "@/lib/timing" +import { useSelection } from "@/hooks/use-selection" +import { flatPages } from "@/lib/infinite" import { PAGE_FILTERS, pageListParams, @@ -16,7 +18,7 @@ import { } from "@/features/pages/pages-filters" import { PageDetail } from "@/features/pages/page-detail" import { PageListPane } from "@/features/pages/page-list-pane" -import { useAdminEventPage, useEventPagesInfinite } from "@/features/pages/use-pages" +import { useEventPage, useEventPageListInfinite } from "@/features/pages/use-pages" import type { SectionPageProps } from "@/components/shell/page-registry" const PAGE_FILTER_LABEL: Record = { @@ -29,36 +31,7 @@ const PAGE_FILTER_LABEL: Record = { const FILTER_OPTIONS = PAGE_FILTERS.map((value) => ({ value, label: PAGE_FILTER_LABEL[value] })) -// Only the first load picks a page on the operator's behalf, and a deep-linked page is never -// replaced. A pick that a filter or search leaves out stays open, read by id; a pick that drops out of -// the same list after a refetch (an unpublish under the Published chip) clears, so the moderation -// buttons never land on a page nobody chose. Decided only on data fetched for the current params. -function usePageSelection( - focusId: string | null, - listQuery: ReturnType, - items: AdminEventPageListItemDTO[], - listKey: string, -) { - const [selectedId, setSelectedId] = React.useState(focusId) - const [autoPick, setAutoPick] = React.useState(focusId === null) - - React.useEffect(() => { - if (focusId) setSelectedId(focusId) - }, [focusId]) - const seenIn = React.useRef<{ id: string; list: string } | null>(null) - React.useEffect(() => { - if (!listQuery.isSuccess || listQuery.isFetching) return - if (selectedId === null) { - if (autoPick && items.length) setSelectedId(items[0]!.cleanupId) - return - } - setAutoPick(false) - if (items.some((item) => item.cleanupId === 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]) - - return [selectedId, setSelectedId] as const -} +const pageId = (item: AdminEventPageListItemDTO) => item.cleanupId function PageDetailPane({ selected, @@ -66,7 +39,7 @@ function PageDetailPane({ pageNoun, }: { selected: AdminEventPageListItemDTO | null - pageQuery: ReturnType + pageQuery: ReturnType pageNoun: string }) { if (selected) return @@ -93,21 +66,26 @@ export function PagesPage({ focusId }: SectionPageProps) { const [filter, setFilter] = React.useState("published") const [query, setQuery] = React.useState("") - const debouncedQuery = useDebounced(query, SEARCH_DEBOUNCE_MS) + const debouncedQuery = useDebounced(query) const listParams = React.useMemo( () => pageListParams(filter, debouncedQuery), [filter, debouncedQuery], ) - const listQuery = useEventPagesInfinite(listParams) + const listQuery = useEventPageListInfinite(listParams) const items = React.useMemo( - () => listQuery.data?.pages.flatMap((page) => page.items) ?? [], + () => flatPages(listQuery.data), [listQuery.data], ) - const [selectedId, setSelectedId] = usePageSelection(focusId, listQuery, items, JSON.stringify(listParams)) + const { selectedId, setSelectedId, selectedItem: listed } = useSelection({ + focusId, + list: listQuery, + items, + getId: pageId, + listKey: JSON.stringify(listParams), + }) - const listed = items.find((item) => item.cleanupId === selectedId) ?? null // The detail's preview reads the same key, so fetching before the list settles never doubles a request. - const pageQuery = useAdminEventPage(listed === null ? selectedId : null) + const pageQuery = useEventPage(listed === null ? selectedId : null) const selected = listed ?? (pageQuery.data ? pageRowFromDTO(pageQuery.data) : null) const pageNoun = selectedId === focusId ? "the linked page" : "the page" @@ -130,16 +108,12 @@ export function PagesPage({ focusId }: SectionPageProps) { onChange={(value) => setFilter(value as PageFilter)} />
-
- - setQuery(e.target.value)} - /> -
+
diff --git a/apps/admin/src/features/pages/use-pages.ts b/apps/admin/src/features/pages/use-pages.ts index 38a4df2..1b4deba 100644 --- a/apps/admin/src/features/pages/use-pages.ts +++ b/apps/admin/src/features/pages/use-pages.ts @@ -1,9 +1,14 @@ "use client" -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query" import type { AdminEventPageListQuery, - AdminEventPageListResponse, AdminGetEventPageResponse, FlagEventPageRequest, FlagEventPageResponse, @@ -12,23 +17,19 @@ import type { } from "@civfix/shared" import { api } from "@/lib/api" -import { queryKeys } from "@/lib/query" +import { infiniteListOptions } from "@/lib/infinite" +import { invalidateKeys, queryKeys } from "@/lib/query" import { publicPagePath } from "@/features/pages/page-path" -export function useEventPagesInfinite(params: AdminEventPageListQuery) { - return useInfiniteQuery({ - queryKey: queryKeys.pages.list(params), - queryFn: ({ pageParam }) => - api.adminListEventPages({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) +export function useEventPageListInfinite(params: AdminEventPageListQuery) { + return useInfiniteQuery( + infiniteListOptions(queryKeys.pages.list(params), params, (input) => + api.adminListEventPages(input), + ), + ) } -export function useAdminEventPage(cleanupId: string | null) { +export function useEventPage(cleanupId: string | null) { return useQuery({ queryKey: queryKeys.pages.preview(cleanupId ?? ""), queryFn: () => api.adminGetEventPage({ id: cleanupId as string }), @@ -40,11 +41,12 @@ function pageName(page: Pick): string { return page.slug ? publicPagePath(page.slug) : page.title } -function invalidatePages(qc: ReturnType) { - return Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.pages.all }), - qc.invalidateQueries({ queryKey: queryKeys.events.all }), - qc.invalidateQueries({ queryKey: queryKeys.audit.all }), +function invalidatePages(qc: QueryClient, id: string) { + return invalidateKeys(qc, [ + queryKeys.pages.all, + queryKeys.events.all, + queryKeys.audit.all, + queryKeys.events.detail(id), ]) } @@ -52,11 +54,7 @@ export function useFlagEventPage() { const qc = useQueryClient() return useMutation({ mutationFn: (input: FlagEventPageRequest) => api.adminFlagEventPage(input), - onSuccess: (_res, { id }) => - Promise.all([ - invalidatePages(qc), - qc.invalidateQueries({ queryKey: queryKeys.events.detail(id) }), - ]), + onSuccess: (_res, { id }) => invalidatePages(qc, id), meta: { successMessage: (page: FlagEventPageResponse, { flagged }: FlagEventPageRequest) => flagged ? `Flagged · ${pageName(page)}` : "Flag cleared", @@ -68,11 +66,7 @@ export function useUnpublishEventPage() { const qc = useQueryClient() return useMutation({ mutationFn: (input: UnpublishEventPageRequest) => api.adminUnpublishEventPage(input), - onSuccess: (_res, { id }) => - Promise.all([ - invalidatePages(qc), - qc.invalidateQueries({ queryKey: queryKeys.events.detail(id) }), - ]), + onSuccess: (_res, { id }) => invalidatePages(qc, id), meta: { successMessage: (page: UnpublishEventPageResponse) => `Unpublished · ${pageName(page)}`, }, diff --git a/apps/admin/src/features/reports/media-thumb.tsx b/apps/admin/src/features/reports/media-thumb.tsx index ab451c0..b90cb09 100644 --- a/apps/admin/src/features/reports/media-thumb.tsx +++ b/apps/admin/src/features/reports/media-thumb.tsx @@ -24,7 +24,6 @@ export function ImageThumbButton({ media, onOpen }: { media: ThumbMedia; onOpen: title="Expand this photo" onClick={onOpen} > - {/* eslint-disable-next-line @next/next/no-img-element */} ) @@ -32,7 +31,6 @@ export function ImageThumbButton({ media, onOpen }: { media: ThumbMedia; onOpen: export function StillThumbFace({ thumbUrl }: { thumbUrl?: string | null }) { return thumbUrl ? ( - // eslint-disable-next-line @next/next/no-img-element ) : ( diff --git a/apps/admin/src/features/reports/person-name.ts b/apps/admin/src/features/reports/person-name.ts deleted file mode 100644 index 1d8adac..0000000 --- a/apps/admin/src/features/reports/person-name.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { INITIALS_MAX_LETTERS } from "@/lib/display" - -export function firstName(name: string): string { - return name.split(" ")[0] ?? name -} - -export function initials(name: string): string { - return name - .split(" ") - .map((w) => w[0] ?? "") - .slice(0, INITIALS_MAX_LETTERS) - .join("") - .toUpperCase() -} diff --git a/apps/admin/src/features/reports/report-activity.tsx b/apps/admin/src/features/reports/report-activity.tsx index 5f690d5..e365810 100644 --- a/apps/admin/src/features/reports/report-activity.tsx +++ b/apps/admin/src/features/reports/report-activity.tsx @@ -50,7 +50,6 @@ export function ReportActivity({ timeline }: { timeline: ReportTimelineItem[] }) ) : (
{timeline.map((t, i) => ( - // eslint-disable-next-line react/no-array-index-key ))}
diff --git a/apps/admin/src/features/reports/report-chat-rows.tsx b/apps/admin/src/features/reports/report-chat-rows.tsx index dae02ab..7c10fe4 100644 --- a/apps/admin/src/features/reports/report-chat-rows.tsx +++ b/apps/admin/src/features/reports/report-chat-rows.tsx @@ -4,9 +4,10 @@ import type { ChatMessageDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { LightboxSync, type LightboxImage } from "@/components/shared/lightbox" +import { formatPreciseDateTime } from "@/lib/dates" +import { initials } from "@/lib/display" import { ImageThumbButton, openLightboxAt, StillThumbFace } from "@/features/reports/media-thumb" -import { initials } from "@/features/reports/person-name" -import { msgWhen, reactionLabel, systemLabel } from "@/features/reports/report-chat" +import { reactionLabel, systemLabel } from "@/features/reports/report-chat" import { useNav } from "@/store/ui-store" type ChatAttachment = NonNullable[number] @@ -24,7 +25,7 @@ export function ChatSystemRow({ msg }: { msg: ChatMessageDTO }) {
{systemLabel(msg)} - {msgWhen(msg.createdAt)} + {formatPreciseDateTime(msg.createdAt)}
@@ -71,7 +72,7 @@ function MessageAuthor({ Forwarded to city )} - {msgWhen(msg.createdAt)} + {formatPreciseDateTime(msg.createdAt)}
) } @@ -172,7 +173,7 @@ export function ChatMessageRow({ {removed ? (

Message removed - {msg.deletedAt ? ` · ${msgWhen(msg.deletedAt)}` : ""} + {msg.deletedAt ? ` · ${formatPreciseDateTime(msg.deletedAt)}` : ""}

) : (

{msg.body}

diff --git a/apps/admin/src/features/reports/report-chat.ts b/apps/admin/src/features/reports/report-chat.ts index a6dbb1f..483f894 100644 --- a/apps/admin/src/features/reports/report-chat.ts +++ b/apps/admin/src/features/reports/report-chat.ts @@ -4,11 +4,6 @@ import { type ReactionEmoji, } from "@civfix/shared" -export function msgWhen(iso: string): string { - const d = new Date(iso) - return Number.isNaN(d.getTime()) ? iso : d.toLocaleString() -} - export function systemLabel(msg: ChatMessageDTO): string { const status = msg.system?.status const detail = msg.system?.note?.trim() || msg.system?.body?.trim() || msg.body?.trim() diff --git a/apps/admin/src/features/reports/report-detail.tsx b/apps/admin/src/features/reports/report-detail.tsx index 6d96242..b0cb9c8 100644 --- a/apps/admin/src/features/reports/report-detail.tsx +++ b/apps/admin/src/features/reports/report-detail.tsx @@ -13,11 +13,11 @@ import { Icons } from "@/components/icons" import { LoadingState, ErrorState } from "@/components/shared/data-states" import { confirmDialog } from "@/components/shared/dialog" import { categoryPinSrc } from "@/lib/category" +import { shortRef } from "@/lib/display" import { reportStatusView } from "@/lib/report-status" import { ReportActivity } from "@/features/reports/report-activity" import { CityMessageCard, RoutedToCard, useCityFollowup } from "@/features/reports/report-city-cards" import { ReportDiscussion } from "@/features/reports/report-discussion" -import { shortId } from "@/features/reports/report-id" import { ReportLinkedEvents } from "@/features/reports/report-linked-events" import { reportMediaView } from "@/features/reports/report-media" import { ReportLocation, ReportPhotos } from "@/features/reports/report-media-panels" @@ -39,13 +39,12 @@ function ReportHead({ report, outreach }: { report: AdminReportDTO; outreach: Ou return (
- {/* eslint-disable-next-line @next/next/no-img-element */}
- {report.referenceCode ?? shortId(report.id)} + {report.referenceCode ?? shortRef(report.id)} {" "} · {REPORT_CATEGORY_LABELS[report.category]} · {report.place}
diff --git a/apps/admin/src/features/reports/report-discussion.tsx b/apps/admin/src/features/reports/report-discussion.tsx index 1681602..d042f4f 100644 --- a/apps/admin/src/features/reports/report-discussion.tsx +++ b/apps/admin/src/features/reports/report-discussion.tsx @@ -7,18 +7,19 @@ import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" import { confirmDialog } from "@/components/shared/dialog" +import { flatPages } from "@/lib/infinite" import { ChatMessageRow, ChatSystemRow } from "@/features/reports/report-chat-rows" import { sortChatOldestFirst } from "@/features/reports/report-chat" import { SubmitShortcutHint, SUBMIT_KEYSHORTCUTS } from "@/features/reports/submit-shortcut" import { useRemoveReportMessage, - useReportChatHistory, + useReportChatListInfinite, useSendReportMessage, } from "@/features/reports/use-reports" const CHAT_COMPOSER_ROWS = 3 -type ChatQuery = ReturnType +type ChatQuery = ReturnType function ChatThread({ reportId, @@ -149,11 +150,11 @@ export function ReportDiscussion({ cityDept: string hasCityContact: boolean }) { - const chatQuery = useReportChatHistory(reportId) + const chatQuery = useReportChatListInfinite(reportId) // Oldest first, matching the order neighbors see in the chat. const items = React.useMemo( - () => sortChatOldestFirst(chatQuery.data?.pages.flatMap((p) => p.items) ?? []), + () => sortChatOldestFirst(flatPages(chatQuery.data)), [chatQuery.data], ) diff --git a/apps/admin/src/features/reports/report-id.ts b/apps/admin/src/features/reports/report-id.ts deleted file mode 100644 index f1fb10c..0000000 --- a/apps/admin/src/features/reports/report-id.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { SHORT_ID_LENGTH } from "@/lib/display" - -export function shortId(id: string): string { - return `#${id.slice(0, SHORT_ID_LENGTH)}` -} diff --git a/apps/admin/src/features/reports/report-linked-events.tsx b/apps/admin/src/features/reports/report-linked-events.tsx index ceb4ece..9fa4f74 100644 --- a/apps/admin/src/features/reports/report-linked-events.tsx +++ b/apps/admin/src/features/reports/report-linked-events.tsx @@ -3,16 +3,11 @@ import type { LinkedEventRef } from "@civfix/shared" import { Icons } from "@/components/icons" +import { formatDate } from "@/lib/dates" +import { firstName } from "@/lib/display" import { eventKindView } from "@/lib/event-kind" -import { firstName } from "@/features/reports/person-name" import { useNav } from "@/store/ui-store" -function eventDate(iso: string): string { - const d = new Date(iso) - if (Number.isNaN(d.getTime())) return iso - return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }) -} - function LinkedEventCard({ event, onOpen }: { event: LinkedEventRef; onOpen: () => void }) { const view = eventKindView(event.eventKind) const KindIcon = view.icon @@ -26,7 +21,7 @@ function LinkedEventCard({ event, onOpen }: { event: LinkedEventRef; onOpen: () {view.label} · - {eventDate(event.scheduledAt)} + {formatDate(event.scheduledAt)} · {`${event.going} going`} diff --git a/apps/admin/src/features/reports/report-list-pane.tsx b/apps/admin/src/features/reports/report-list-pane.tsx index 11064c1..741bb5b 100644 --- a/apps/admin/src/features/reports/report-list-pane.tsx +++ b/apps/admin/src/features/reports/report-list-pane.tsx @@ -4,56 +4,10 @@ import type { AdminReportListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" -import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { ListCard, ListStates, LoadMoreButton } from "@/components/shared/section-list" import { ReportRow } from "@/features/reports/report-row" import type { useReportListInfinite } from "@/features/reports/use-reports" -type ReportListQuery = ReturnType - -function ReportListItems({ - listQuery, - items, - selId, - onSelect, -}: { - listQuery: ReportListQuery - items: AdminReportListItemDTO[] - selId: string | null - onSelect: (id: string) => void -}) { - if (listQuery.isLoading) return - if (listQuery.isError) { - return listQuery.refetch()} /> - } - if (items.length === 0) { - return ( - } - /> - ) - } - return ( - <> - {items.map((r) => ( - - ))} - {listQuery.hasNextPage && ( - - )} - - ) -} - export function ReportListPane({ listQuery, items, @@ -63,7 +17,7 @@ export function ReportListPane({ pinnedLabel, onSelect, }: { - listQuery: ReportListQuery + listQuery: ReturnType items: AdminReportListItemDTO[] listCount: number selId: string | null @@ -72,23 +26,32 @@ export function ReportListPane({ onSelect: (id: string) => void }) { return ( -
-
-

Reports

-
- {listCount} -
-
- {pinned && ( - <> -
- {pinnedLabel} -
- - - )} - -
-
+ + {pinned && ( + <> +
+ {pinnedLabel} +
+ + + )} + } + /> + } + > + {items.map((r) => ( + + ))} + + +
) } diff --git a/apps/admin/src/features/reports/report-media-panels.tsx b/apps/admin/src/features/reports/report-media-panels.tsx index 97cddac..670f6f5 100644 --- a/apps/admin/src/features/reports/report-media-panels.tsx +++ b/apps/admin/src/features/reports/report-media-panels.tsx @@ -35,11 +35,9 @@ function ReportPhotoFace({ return ( <> {photoUrl ? ( - // eslint-disable-next-line @next/next/no-img-element Reporter photo ) : ( - {/* eslint-disable-next-line @next/next/no-img-element */} )} diff --git a/apps/admin/src/features/reports/report-reporter-card.tsx b/apps/admin/src/features/reports/report-reporter-card.tsx index b721931..30f75a9 100644 --- a/apps/admin/src/features/reports/report-reporter-card.tsx +++ b/apps/admin/src/features/reports/report-reporter-card.tsx @@ -2,13 +2,13 @@ import type { AdminReportDTO } from "@civfix/shared" -import { getReporterProfileId } from "@/features/reports/reporter-navigation" -import { initials } from "@/features/reports/person-name" +import { toReporterProfileId } from "@/features/reports/reporter-navigation" +import { initials } from "@/lib/display" import { useNav } from "@/store/ui-store" export function ReporterCard({ reporter }: { reporter: AdminReportDTO["reporter"] }) { const nav = useNav() - const reporterProfileId = getReporterProfileId(reporter.id) + const reporterProfileId = toReporterProfileId(reporter.id) return (
Reporter
diff --git a/apps/admin/src/features/reports/report-row.tsx b/apps/admin/src/features/reports/report-row.tsx index 3da7005..317d40b 100644 --- a/apps/admin/src/features/reports/report-row.tsx +++ b/apps/admin/src/features/reports/report-row.tsx @@ -6,11 +6,10 @@ import { REPORT_CATEGORY_LABELS, type AdminReportListItemDTO } from "@civfix/sha import { Icons } from "@/components/icons" import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" import { categoryCssVar, categoryPinSrc } from "@/lib/category" +import { firstName, shortRef } from "@/lib/display" import { reportStatusView } from "@/lib/report-status" -import { getReporterProfileId } from "@/features/reports/reporter-navigation" -import { firstName } from "@/features/reports/person-name" +import { toReporterProfileId } from "@/features/reports/reporter-navigation" import { pluralize } from "@/features/reports/plural" -import { shortId } from "@/features/reports/report-id" import { useNav } from "@/store/ui-store" function ReportRowLeading({ item }: { item: AdminReportListItemDTO }) { @@ -26,7 +25,6 @@ function ReportRowLeading({ item }: { item: AdminReportListItemDTO }) { title={categoryLabel} > {thumb ? ( - // eslint-disable-next-line @next/next/no-img-element setBrokenThumb(thumb)} /> ) : ( - // eslint-disable-next-line @next/next/no-img-element )}
@@ -44,7 +41,7 @@ function ReportRowLeading({ item }: { item: AdminReportListItemDTO }) { function ReporterName({ reporter }: { reporter: AdminReportListItemDTO["reporter"] }) { const nav = useNav() - const reporterId = getReporterProfileId(reporter.id) + const reporterId = toReporterProfileId(reporter.id) if (!reporterId) return {firstName(reporter.name)} return (
diff --git a/apps/admin/src/features/reports/report-send-card.tsx b/apps/admin/src/features/reports/report-send-card.tsx index 60e7b91..2b513ea 100644 --- a/apps/admin/src/features/reports/report-send-card.tsx +++ b/apps/admin/src/features/reports/report-send-card.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { RouteReportRequestSchema, type AdminReportDTO } from "@civfix/shared" import { Icons } from "@/components/icons" -import { msgWhen } from "@/features/reports/report-chat" +import { formatPreciseDateTime } from "@/lib/dates" import type { SendPanelView } from "@/features/reports/send-panel" import { reportVerdictActions, @@ -35,7 +35,7 @@ function ReporterVerification({ report }: { report: AdminReportDTO }) { <> · - {msgWhen(report.verifiedAt)} + {formatPreciseDateTime(report.verifiedAt)} )} diff --git a/apps/admin/src/features/reports/reporter-navigation.test.ts b/apps/admin/src/features/reports/reporter-navigation.test.ts index bbbcf51..325ff7e 100644 --- a/apps/admin/src/features/reports/reporter-navigation.test.ts +++ b/apps/admin/src/features/reports/reporter-navigation.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest" -import { getReporterProfileId } from "./reporter-navigation" +import { toReporterProfileId } from "./reporter-navigation" -describe("getReporterProfileId", () => { +describe("toReporterProfileId", () => { it("returns no destination for anonymous ids and preserves actual user ids", () => { - expect(getReporterProfileId(null)).toBeNull() - expect(getReporterProfileId("")).toBeNull() - expect(getReporterProfileId(" ")).toBeNull() - expect(getReporterProfileId("USER-1")).toBe("USER-1") + expect(toReporterProfileId(null)).toBeNull() + expect(toReporterProfileId("")).toBeNull() + expect(toReporterProfileId(" ")).toBeNull() + expect(toReporterProfileId("USER-1")).toBe("USER-1") }) }) diff --git a/apps/admin/src/features/reports/reporter-navigation.ts b/apps/admin/src/features/reports/reporter-navigation.ts index 2e5b4f0..88dd75b 100644 --- a/apps/admin/src/features/reports/reporter-navigation.ts +++ b/apps/admin/src/features/reports/reporter-navigation.ts @@ -1,4 +1,4 @@ -export function getReporterProfileId(id: string | null): string | null { +export function toReporterProfileId(id: string | null): string | null { const normalized = id?.trim() ?? "" return normalized === "" ? null : normalized } diff --git a/apps/admin/src/features/reports/reports-page.tsx b/apps/admin/src/features/reports/reports-page.tsx index ba5f635..7349429 100644 --- a/apps/admin/src/features/reports/reports-page.tsx +++ b/apps/admin/src/features/reports/reports-page.tsx @@ -5,9 +5,12 @@ import type { AdminReportCounts } from "@civfix/shared" import { Icons } from "@/components/icons" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" +import { SearchBox } from "@/components/shared/section-list" import { ReportDetail } from "@/features/reports/report-detail" import { ReportListPane } from "@/features/reports/report-list-pane" import { useDebounced } from "@/hooks/use-debounced" +import { idOf, useSelection } from "@/hooks/use-selection" +import { flatPages } from "@/lib/infinite" import { useReport, useReportListInfinite } from "@/features/reports/use-reports" import type { SectionPageProps } from "@/components/shell/page-registry" @@ -47,7 +50,6 @@ export function ReportsPage({ focusId }: SectionPageProps) { const [filter, setFilter] = React.useState("needs_verification") const [query, setQuery] = React.useState("") const debouncedQuery = useDebounced(query) - const [selId, setSelId] = React.useState(focusId) const listParams = { filter: filter === "all" ? undefined : filter, @@ -55,7 +57,7 @@ export function ReportsPage({ focusId }: SectionPageProps) { } const listQuery = useReportListInfinite(listParams) const items = React.useMemo( - () => listQuery.data?.pages.flatMap((p) => p.items) ?? [], + () => flatPages(listQuery.data), [listQuery.data], ) @@ -63,33 +65,23 @@ export function ReportsPage({ focusId }: SectionPageProps) { const counts = serverCounts ?? EMPTY_COUNTS const listCount = serverCounts ? (serverCounts[FILTER_COUNT_KEY[filter]] ?? 0) : items.length - // Only the first load picks a report on the operator's behalf, and a deep-linked report is never - // replaced. A pick that a filter or search leaves out stays open, pinned above the list and read by - // id; a pick that drops out of the same list after a refetch (a verdict under the Needs verification - // chip) clears, so the pane never jumps to another report's live buttons. A removal clears its report - // outright, since a removed report no longer loads by id. Decided only on data fetched for the - // current params. - const listKey = JSON.stringify(listParams) - const [autoPick, setAutoPick] = React.useState(focusId === null) - const seenIn = React.useRef<{ id: string; list: string } | null>(null) - React.useEffect(() => { - if (focusId) setSelId(focusId) - }, [focusId]) - React.useEffect(() => { - if (!listQuery.isSuccess || listQuery.isFetching) return - if (selId === null) { - if (autoPick && items.length) setSelId(items[0]!.id) - return - } - setAutoPick(false) - if (items.some((x) => x.id === selId)) seenIn.current = { id: selId, list: listKey } - else if (seenIn.current?.id === selId && seenIn.current.list === listKey) setSelId(null) - }, [listQuery.isSuccess, listQuery.isFetching, items, selId, listKey, autoPick]) - const clearIfSelected = (id: string) => setSelId((cur) => (cur === id ? null : cur)) + // A removal clears its report outright, since a removed report no longer loads by id. + const { + selectedId: selId, + setSelectedId: setSelId, + selectedItem: listed, + clearIfSelected, + } = useSelection({ + focusId, + list: listQuery, + items, + getId: idOf, + listKey: JSON.stringify(listParams), + }) - const selInList = selId !== null && items.some((x) => x.id === selId) - const pinnedQuery = useReport(selInList ? null : selId) - const pinned = selInList ? null : (pinnedQuery.data ?? null) + // A pick the list leaves out is pinned above it, read by id. + const pinnedQuery = useReport(listed ? null : selId) + const pinned = listed ? null : (pinnedQuery.data ?? null) return ( <> @@ -110,16 +102,12 @@ export function ReportsPage({ focusId }: SectionPageProps) { onChange={(v) => setFilter(v as ReportFilter)} />
-
- - setQuery(e.target.value)} - /> -
+
diff --git a/apps/admin/src/features/reports/route-action-send-failed.test.ts b/apps/admin/src/features/reports/route-action-send-failed.test.ts index f232d4d..21f5be5 100644 --- a/apps/admin/src/features/reports/route-action-send-failed.test.ts +++ b/apps/admin/src/features/reports/route-action-send-failed.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { routeActionFor, type RoutableReport } from "./route-action" +import { routeActionView, type RoutableReport } from "./route-action" function routedReport(outreach: Partial): RoutableReport { return { @@ -16,17 +16,17 @@ function routedReport(outreach: Partial): RoutableRe } } -describe("routeActionFor with a failed send", () => { +describe("routeActionView with a failed send", () => { it("offers a resend even when the outreach status reads delivered or replied", () => { for (const status of ["delivered", "replied"] as const) { - expect(routeActionFor(routedReport({ status, sendFailed: true })).kind).toBe("resend") + expect(routeActionView(routedReport({ status, sendFailed: true })).kind).toBe("resend") } }) it("treats an explicit sendFailed false like an absent flag", () => { - expect(routeActionFor(routedReport({ status: "sent", sendFailed: false })).kind).toBe( + expect(routeActionView(routedReport({ status: "sent", sendFailed: false })).kind).toBe( "already_sent", ) - expect(routeActionFor(routedReport({ status: "not_sent", sendFailed: false })).kind).toBe("send") + expect(routeActionView(routedReport({ status: "not_sent", sendFailed: false })).kind).toBe("send") }) }) diff --git a/apps/admin/src/features/reports/route-action.test.ts b/apps/admin/src/features/reports/route-action.test.ts index e4c8d37..6e2970d 100644 --- a/apps/admin/src/features/reports/route-action.test.ts +++ b/apps/admin/src/features/reports/route-action.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import { queryKeys } from "@/lib/query" -import { routeActionFor, routeSendLabel, type RoutableReport } from "./route-action" +import { routeActionView, routeSendLabel, type RoutableReport } from "./route-action" function makeReport(over: Partial = {}): RoutableReport { return { @@ -12,28 +12,28 @@ function makeReport(over: Partial = {}): RoutableReport { } } -describe("routeActionFor", () => { +describe("routeActionView", () => { it("offers the send when a contact is on file and nothing has gone out", () => { - expect(routeActionFor(makeReport()).kind).toBe("send") + expect(routeActionView(makeReport()).kind).toBe("send") }) it("blocks the send when the jurisdiction has no contact", () => { - expect(routeActionFor(makeReport({ city: { ...makeReport().city, contact: null } })).kind).toBe( + expect(routeActionView(makeReport({ city: { ...makeReport().city, contact: null } })).kind).toBe( "no_contact", ) - expect(routeActionFor(makeReport({ city: { ...makeReport().city, contact: "" } })).kind).toBe( + expect(routeActionView(makeReport({ city: { ...makeReport().city, contact: "" } })).kind).toBe( "no_contact", ) }) it("reports a missing jurisdiction separately from a missing contact", () => { const report = makeReport({ geoid: null, city: { ...makeReport().city, contact: null } }) - expect(routeActionFor(report).kind).toBe("no_jurisdiction") + expect(routeActionView(report).kind).toBe("no_jurisdiction") }) it("marks already-sent outreach and carries the routed timestamp", () => { for (const status of ["sent", "delivered", "replied"] as const) { - const action = routeActionFor( + const action = routeActionView( makeReport({ outreach: { status, @@ -49,7 +49,7 @@ describe("routeActionFor", () => { }) it("re-enables the send after a bounce", () => { - const action = routeActionFor( + const action = routeActionView( makeReport({ outreach: { status: "bounced", @@ -63,7 +63,7 @@ describe("routeActionFor", () => { }) it("re-enables the send when the last attempt was rejected by the provider", () => { - const action = routeActionFor( + const action = routeActionView( makeReport({ outreach: { status: "sent", diff --git a/apps/admin/src/features/reports/route-action.ts b/apps/admin/src/features/reports/route-action.ts index b47a1b4..e04d366 100644 --- a/apps/admin/src/features/reports/route-action.ts +++ b/apps/admin/src/features/reports/route-action.ts @@ -16,7 +16,7 @@ export type RoutableReport = Pick const SENT_OUTREACH_STATUSES: readonly ReportOutreachStatus[] = ["sent", "delivered", "replied"] -export function routeActionFor(report: RoutableReport): RouteAction { +export function routeActionView(report: RoutableReport): RouteAction { const routedAt = report.outreach.routedAt if (!report.city.contact) { return { kind: report.geoid === null ? "no_jurisdiction" : "no_contact", routedAt } diff --git a/apps/admin/src/features/reports/send-panel.test.ts b/apps/admin/src/features/reports/send-panel.test.ts index 0c93384..fd73638 100644 --- a/apps/admin/src/features/reports/send-panel.test.ts +++ b/apps/admin/src/features/reports/send-panel.test.ts @@ -44,7 +44,7 @@ describe("sendPanelView", () => { expect(view.routeAction.kind).toBe("already_sent") expect(view.approvesOnSend).toBe(false) expect(view.canApproveHere).toBe(true) - expect(view.routeLabel).toBe("Already sent · 9/22/2026, 10:00:00 AM") + expect(view.routeLabel).toBe("Already sent · Sep 22, 2026, 10:00:00 AM") expect(view.routeBlocked).toBe("This report was already sent. Resend it from the Mail thread.") }) diff --git a/apps/admin/src/features/reports/send-panel.ts b/apps/admin/src/features/reports/send-panel.ts index 5334cbf..3592fdd 100644 --- a/apps/admin/src/features/reports/send-panel.ts +++ b/apps/admin/src/features/reports/send-panel.ts @@ -1,8 +1,8 @@ import type { AdminReportDTO } from "@civfix/shared" -import { msgWhen } from "@/features/reports/report-chat" +import { formatPreciseDateTime } from "@/lib/dates" import { - routeActionFor, + routeActionView, routeSendLabel, type RoutableReport, type RouteAction, @@ -30,7 +30,7 @@ export interface SendPanelView { function routeButtonLabel(action: RouteAction, verdictApproved: boolean): string { if (action.kind === "already_sent") { - return action.routedAt ? `Already sent · ${msgWhen(action.routedAt)}` : "Already sent" + return action.routedAt ? `Already sent · ${formatPreciseDateTime(action.routedAt)}` : "Already sent" } if (action.kind === "resend") return "Send again to jurisdiction" return routeSendLabel(verdictApproved) @@ -39,7 +39,7 @@ function routeButtonLabel(action: RouteAction, verdictApproved: boolean): string // A local approve outranks the server verdict until the refetch lands, so the panel never offers a // second approve (or blocks a reject) on a verdict the operator just changed. export function sendPanelView(report: SendPanelReport, approvedLocally: boolean): SendPanelView { - const routeAction = routeActionFor(report) + const routeAction = routeActionView(report) const verdictApproved = report.verificationVerdict === "approved" || approvedLocally const approvesOnSend = routeAction.kind === "send" && !verdictApproved return { diff --git a/apps/admin/src/features/reports/use-reports.ts b/apps/admin/src/features/reports/use-reports.ts index cc504ea..59b6aa8 100644 --- a/apps/admin/src/features/reports/use-reports.ts +++ b/apps/admin/src/features/reports/use-reports.ts @@ -7,6 +7,7 @@ import { useMutation, useQuery, useQueryClient, + type QueryClient, } from "@tanstack/react-query" import { ADMIN_REPORT_STATUS_LABELS, @@ -14,7 +15,6 @@ import { type AdminReportListQuery, type AdminReportListResponse, type AdminSendReportMessageRequest, - type ChatHistoryResponse, type FlagReportRequest, type GetAdminReportResponse, type RemoveReportRequest, @@ -25,12 +25,13 @@ import { } from "@civfix/shared" import { api } from "@/lib/api" -import { queryKeys } from "@/lib/query" +import { shortRef } from "@/lib/display" +import { infiniteListOptions } from "@/lib/infinite" +import { invalidateKeys, queryKeys } from "@/lib/query" import { useUiStore } from "@/store/ui-store" -import { shortId } from "@/features/reports/report-id" function reportMessage(id: string, text: string): string { - return `${shortId(id)} · ${text}` + return `${shortRef(id)} · ${text}` } export function useReportList(params: AdminReportListQuery) { @@ -44,15 +45,10 @@ export function useReportListInfinite( params: AdminReportListQuery, opts: { keepPreviousData?: boolean } = {}, ) { - return useInfiniteQuery({ - queryKey: queryKeys.reports.list(params), - queryFn: ({ pageParam }) => - api.listAdminReports({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + return useInfiniteQuery({ + ...infiniteListOptions(queryKeys.reports.list(params), params, (input) => + api.listAdminReports(input), + ), ...(opts.keepPreviousData ? { placeholderData: keepPreviousData } : {}), }) } @@ -71,19 +67,19 @@ export function useReport(id: string | null) { export function useRefreshReportMedia(id: string): () => void { const qc = useQueryClient() return () => { - qc.invalidateQueries({ queryKey: queryKeys.reports.detail(id) }) + void qc.invalidateQueries({ queryKey: queryKeys.reports.detail(id) }) } } // Profiles, event detail and the moderation queue all render a report's status and flag. -function invalidateReports(qc: ReturnType, id: string) { - return Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.reports.detail(id) }), - qc.invalidateQueries({ queryKey: queryKeys.reports.all }), - qc.invalidateQueries({ queryKey: queryKeys.home.all }), - qc.invalidateQueries({ queryKey: queryKeys.users.all }), - qc.invalidateQueries({ queryKey: queryKeys.events.all }), - qc.invalidateQueries({ queryKey: queryKeys.moderation.all }), +function invalidateReports(qc: QueryClient, id: string) { + return invalidateKeys(qc, [ + queryKeys.reports.detail(id), + queryKeys.reports.all, + queryKeys.home.all, + queryKeys.users.all, + queryKeys.events.all, + queryKeys.moderation.all, ]) } @@ -178,17 +174,14 @@ 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 ?? ""), - queryFn: ({ pageParam }) => - api.adminReportMessages({ - id: id as string, - limit: REPORT_CHAT_LIMIT, - ...(typeof pageParam === "string" ? { before: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, +export function useReportChatListInfinite(id: string | null) { + return useInfiniteQuery({ + ...infiniteListOptions( + queryKeys.reports.chat(id ?? ""), + { id: id as string, limit: REPORT_CHAT_LIMIT }, + ({ cursor, ...input }) => + api.adminReportMessages({ ...input, ...(cursor !== undefined ? { before: cursor } : {}) }), + ), enabled: !!id, }) } @@ -198,10 +191,7 @@ export function useSendReportMessage() { return useMutation({ mutationFn: (input: AdminSendReportMessageRequest) => api.adminSendReportMessage(input), onSuccess: (_res, { id }) => - Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.reports.chat(id) }), - qc.invalidateQueries({ queryKey: queryKeys.reports.detail(id) }), - ]), + invalidateKeys(qc, [queryKeys.reports.chat(id), queryKeys.reports.detail(id)]), meta: { successMessage: () => "Message posted to the report chat" }, }) } diff --git a/apps/admin/src/features/users/profile-activity-navigation.test.ts b/apps/admin/src/features/users/profile-activity-navigation.test.ts index 9fd21b1..56a010c 100644 --- a/apps/admin/src/features/users/profile-activity-navigation.test.ts +++ b/apps/admin/src/features/users/profile-activity-navigation.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from "vitest" -import { getUserMessageDestination } from "./profile-activity-navigation" +import { userMessageDestination } from "./profile-activity-navigation" describe("profile activity row navigation", () => { it("navigates only sources with a matching admin detail page", () => { - expect(getUserMessageDestination({ source: "chat", sourceId: "event-1" })).toEqual({ + expect(userMessageDestination({ source: "chat", sourceId: "event-1" })).toEqual({ page: "events", id: "event-1", }) - expect(getUserMessageDestination({ source: "report", sourceId: "report-1" })).toEqual({ + expect(userMessageDestination({ source: "report", sourceId: "report-1" })).toEqual({ page: "reports", id: "report-1", }) - expect(getUserMessageDestination({ source: "group", sourceId: "group-1" })).toBeNull() - expect(getUserMessageDestination({ source: "dm", sourceId: null })).toBeNull() + expect(userMessageDestination({ source: "group", sourceId: "group-1" })).toBeNull() + expect(userMessageDestination({ source: "dm", sourceId: null })).toBeNull() }) }) diff --git a/apps/admin/src/features/users/profile-activity-navigation.ts b/apps/admin/src/features/users/profile-activity-navigation.ts index 104d3c8..a222aa6 100644 --- a/apps/admin/src/features/users/profile-activity-navigation.ts +++ b/apps/admin/src/features/users/profile-activity-navigation.ts @@ -1,6 +1,6 @@ type UserMessageDestination = { page: "events" | "reports"; id: string } -export function getUserMessageDestination(input: { +export function userMessageDestination(input: { source?: string | null sourceId: string | null }): UserMessageDestination | null { diff --git a/apps/admin/src/features/users/profile-activity-rows.tsx b/apps/admin/src/features/users/profile-activity-rows.tsx index bab8e00..00bfc63 100644 --- a/apps/admin/src/features/users/profile-activity-rows.tsx +++ b/apps/admin/src/features/users/profile-activity-rows.tsx @@ -14,7 +14,7 @@ import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation import { categoryPinSrc } from "@/lib/category" import { reportStatusView } from "@/lib/report-status" import { isMissing, type NavFn } from "@/features/users/user-display" -import { getUserMessageDestination } from "./profile-activity-navigation" +import { userMessageDestination } from "./profile-activity-navigation" const SOURCE_LABEL: Record, string> = { chat: "Cleanup chat", @@ -51,7 +51,6 @@ export function ProfileReportRow({ report, nav }: { report: UserReportItemDTO; n onKeyDown={onActivationKey(open)} > - {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -113,7 +112,7 @@ export function ProfileMessageRow({ }) { const removed = !!message.deletedAt const source = message.source - const destination = getUserMessageDestination(message) + const destination = userMessageDestination(message) const open = destination ? () => nav(destination.page, destination.id) : undefined const content = ( <> diff --git a/apps/admin/src/features/users/use-users.ts b/apps/admin/src/features/users/use-users.ts index 3ef3a96..467ffb6 100644 --- a/apps/admin/src/features/users/use-users.ts +++ b/apps/admin/src/features/users/use-users.ts @@ -6,6 +6,8 @@ import { useMutation, useQuery, useQueryClient, + type QueryClient, + type QueryKey, } from "@tanstack/react-query" import type { AdminUserListQuery, @@ -15,13 +17,11 @@ import type { RemoveUserMessageRequest, SetUserReportVerifiedRequest, SetUserStatusRequest, - UserEventsResponse, - UserMessagesResponse, - UserReportsResponse, } from "@civfix/shared" import { api } from "@/lib/api" -import { queryKeys } from "@/lib/query" +import { infiniteListOptions, type CursorPage } from "@/lib/infinite" +import { invalidateKeys, queryKeys } from "@/lib/query" /** * One flat page of users (home preview, the org user picker). Keyed under `users.page`, not @@ -37,16 +37,9 @@ export function useUserList(params: AdminUserListQuery, opts: { keepPreviousData } export function useUserListInfinite(params: AdminUserListQuery) { - return useInfiniteQuery({ - queryKey: queryKeys.users.list(params), - queryFn: ({ pageParam }) => - api.listAdminUsers({ - ...params, - ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), - }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, - }) + return useInfiniteQuery( + infiniteListOptions(queryKeys.users.list(params), params, (input) => api.listAdminUsers(input)), + ) } export function useUser(id: string | null) { @@ -59,45 +52,43 @@ export function useUser(id: string | null) { // A profile tab's badge counts everything the user has, so each tab pages through the whole history // rather than stopping at the API's first page. -function useUserSubList( - queryKey: readonly unknown[], +function useUserSubListInfinite( + queryKey: TKey, id: string | null, - fetchPage: (input: { id: string; cursor?: string }) => Promise, + fetchPage: (input: { id: string; cursor?: string }) => Promise, ) { - return useInfiniteQuery({ - queryKey, - queryFn: ({ pageParam }) => - fetchPage({ id: id as string, ...(typeof pageParam === "string" ? { cursor: pageParam } : {}) }), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + return useInfiniteQuery({ + ...infiniteListOptions(queryKey, { id: id as string }, fetchPage), enabled: !!id, }) } -export function useUserReports(id: string | null) { - return useUserSubList(queryKeys.users.reports(id ?? ""), id, (input) => - api.getUserReports(input), +export function useUserReportListInfinite(id: string | null) { + return useUserSubListInfinite( + queryKeys.users.reports(id ?? ""), + id, + (input) => api.getUserReports(input), ) } -export function useUserEvents(id: string | null) { - return useUserSubList(queryKeys.users.events(id ?? ""), id, (input) => - api.getUserEvents(input), +export function useUserEventListInfinite(id: string | null) { + return useUserSubListInfinite( + queryKeys.users.events(id ?? ""), + id, + (input) => api.getUserEvents(input), ) } -export function useUserMessages(id: string | null) { - return useUserSubList(queryKeys.users.messages(id ?? ""), id, (input) => - api.getUserMessages(input), +export function useUserMessageListInfinite(id: string | null) { + return useUserSubListInfinite( + queryKeys.users.messages(id ?? ""), + id, + (input) => api.getUserMessages(input), ) } -function invalidateUsers(qc: ReturnType, id: string) { - return Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.users.detail(id) }), - qc.invalidateQueries({ queryKey: queryKeys.users.all }), - qc.invalidateQueries({ queryKey: queryKeys.home.all }), - ]) +function invalidateUsers(qc: QueryClient, id: string) { + return invalidateKeys(qc, [queryKeys.users.detail(id), queryKeys.users.all, queryKeys.home.all]) } export interface FlagUserVariables { @@ -165,11 +156,7 @@ export function useRemoveUserMessage() { const qc = useQueryClient() return useMutation({ mutationFn: (input: RemoveUserMessageRequest) => api.removeUserMessage(input), - onSuccess: (_res, { id }) => - Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.users.messages(id) }), - invalidateUsers(qc, id), - ]), + onSuccess: (_res, { id }) => invalidateUsers(qc, id), meta: { successMessage: () => "Message removed" }, }) } diff --git a/apps/admin/src/features/users/user-activity.tsx b/apps/admin/src/features/users/user-activity.tsx index 365b1df..7016a59 100644 --- a/apps/admin/src/features/users/user-activity.tsx +++ b/apps/admin/src/features/users/user-activity.tsx @@ -5,13 +5,14 @@ import type { UserMessageItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" -import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { ListStates, LoadMoreButton } from "@/components/shared/section-list" import { promptDialog } from "@/components/shared/dialog" +import { flatPages } from "@/lib/infinite" import { useRemoveUserMessage, - useUserEvents, - useUserMessages, - useUserReports, + useUserEventListInfinite, + useUserMessageListInfinite, + useUserReportListInfinite, } from "@/features/users/use-users" import { ProfileEventRow, @@ -33,20 +34,6 @@ interface ActivityQuery { fetchNextPage: () => unknown } -function LoadMore({ query }: { query: ActivityQuery }) { - if (!query.hasNextPage) return null - return ( - - ) -} - function ActivityList({ query, loadingLabel, @@ -58,23 +45,25 @@ function ActivityList({ empty: { title: string; sub: string; icon: React.ReactNode } renderItem: (item: T) => React.ReactNode }) { - if (query.isLoading) return - if (query.isError) return query.refetch()} /> - const items = query.data?.pages.flatMap((p) => p.items) ?? [] - if (!items.length) return + const items = flatPages(query.data) return ( - <> + } + > {items.map(renderItem)} - - + + ) } export function UserActivity({ userId, tab }: { userId: string; tab: ProfileTab }) { const nav = useNav() - const reports = useUserReports(tab === "reports" ? userId : null) - const events = useUserEvents(tab === "events" ? userId : null) - const messages = useUserMessages(tab === "messages" ? userId : null) + const reports = useUserReportListInfinite(tab === "reports" ? userId : null) + const events = useUserEventListInfinite(tab === "events" ? userId : null) + const messages = useUserMessageListInfinite(tab === "messages" ? userId : null) const removeMessage = useRemoveUserMessage() const onRemoveMessage = async (message: UserMessageItemDTO) => { diff --git a/apps/admin/src/features/users/user-avatar.tsx b/apps/admin/src/features/users/user-avatar.tsx index ee79cab..fa92bad 100644 --- a/apps/admin/src/features/users/user-avatar.tsx +++ b/apps/admin/src/features/users/user-avatar.tsx @@ -15,7 +15,6 @@ export function UserAvatar({ const className = `user-av${large ? " lg" : ""}` if (user.avatarUrl) { return ( - // eslint-disable-next-line @next/next/no-img-element ) } diff --git a/apps/admin/src/features/users/user-detail.tsx b/apps/admin/src/features/users/user-detail.tsx index 846e2c4..790a321 100644 --- a/apps/admin/src/features/users/user-detail.tsx +++ b/apps/admin/src/features/users/user-detail.tsx @@ -7,6 +7,7 @@ import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" import { LoadingState, ErrorState } from "@/components/shared/data-states" import { isNotFound } from "@/lib/api" +import { formatDate } from "@/lib/dates" import { EMPTY_VALUE } from "@/lib/empty-value" import { menuFocusIndex } from "@/features/orgs/org-members" import { useUser } from "@/features/users/use-users" @@ -23,12 +24,6 @@ const PROFILE_TABS: readonly { id: ProfileTab; label: string }[] = [ { id: "messages", label: "Messages" }, ] -function formatJoinDate(value: string): string { - const date = new Date(value) - if (Number.isNaN(date.getTime())) return value - return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) -} - function tabCount(user: AdminUserDTO, tab: ProfileTab): number { if (tab === "reports") return user.reports if (tab === "events") return user.cleanups @@ -98,7 +93,7 @@ function UserProfileMeta({ user }: { user: AdminUserDTO }) { Joined{" "} - {isMissing(user.joined) ? "unknown" : formatJoinDate(user.joined)} + {isMissing(user.joined) ? "unknown" : formatDate(user.joined)} {!isMissing(user.city) && ( diff --git a/apps/admin/src/features/users/user-list-pane.tsx b/apps/admin/src/features/users/user-list-pane.tsx index 927430d..fe3ba77 100644 --- a/apps/admin/src/features/users/user-list-pane.tsx +++ b/apps/admin/src/features/users/user-list-pane.tsx @@ -4,87 +4,40 @@ import type { AdminUserListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" import { EmptyState } from "@/components/shared/page-primitives" -import { LoadingState, ErrorState } from "@/components/shared/data-states" +import { ListCard, ListStates, LoadMoreButton } from "@/components/shared/section-list" import { UserRow } from "@/features/users/user-row" import type { useUserListInfinite } from "@/features/users/use-users" -type UserListQuery = ReturnType - -function UserListItems({ - listQuery, - items, - selectedId, - onSelect, -}: { - listQuery: UserListQuery - items: AdminUserListItemDTO[] - selectedId: string | null - onSelect: (id: string) => void -}) { - if (listQuery.isLoading) return - if (listQuery.isError) { - return listQuery.refetch()} /> - } - if (items.length === 0) { - return ( - } - /> - ) - } - return ( - <> - {items.map((u) => ( - onSelect(u.id)} - /> - ))} - {listQuery.hasNextPage && ( - - )} - - ) -} - export function UserListPane({ listQuery, items, selectedId, onSelect, }: { - listQuery: UserListQuery + listQuery: ReturnType items: AdminUserListItemDTO[] selectedId: string | null onSelect: (id: string) => void }) { return ( -
-
-

Accounts

-
- {items.length} -
-
- -
-
+ + } + /> + } + > + {items.map((u) => ( + + ))} + + + ) } diff --git a/apps/admin/src/features/users/user-row.tsx b/apps/admin/src/features/users/user-row.tsx index 25415e9..eaa5c99 100644 --- a/apps/admin/src/features/users/user-row.tsx +++ b/apps/admin/src/features/users/user-row.tsx @@ -1,5 +1,6 @@ "use client" +import * as React from "react" import type { AdminUserListItemDTO } from "@civfix/shared" import { Icons } from "@/components/icons" @@ -8,14 +9,14 @@ import { EMPTY_VALUE } from "@/lib/empty-value" import { UserAvatar } from "@/features/users/user-avatar" import { isMissing, userStatusView } from "@/features/users/user-display" -export function UserRow({ +export const UserRow = React.memo(function UserRow({ user, selected, - onClick, + onSelect, }: { user: AdminUserListItemDTO selected: boolean - onClick: () => void + onSelect: (id: string) => void }) { const statusView = userStatusView(user.status) return ( @@ -24,11 +25,11 @@ export function UserRow({ role="button" tabIndex={0} aria-current={selected ? "true" : undefined} - onClick={onClick} + onClick={() => onSelect(user.id)} onKeyDown={(e) => { if (!isKeyboardActivationKey(e.key)) return e.preventDefault() - onClick() + onSelect(user.id) }} > @@ -68,4 +69,4 @@ export function UserRow({
) -} +}) diff --git a/apps/admin/src/features/users/users-page.tsx b/apps/admin/src/features/users/users-page.tsx index 3ab6cd6..67ed489 100644 --- a/apps/admin/src/features/users/users-page.tsx +++ b/apps/admin/src/features/users/users-page.tsx @@ -5,8 +5,10 @@ import type { AdminUserListQuery, AdminUserListResponse } from "@civfix/shared" import { Icons } from "@/components/icons" import { PageHead, FilterChips, EmptyState } from "@/components/shared/page-primitives" +import { SearchBox } from "@/components/shared/section-list" import { useDebounced } from "@/hooks/use-debounced" -import { SEARCH_DEBOUNCE_MS } from "@/lib/timing" +import { idOf, useSelection } from "@/hooks/use-selection" +import { flatPages } from "@/lib/infinite" import { useUserListInfinite } from "@/features/users/use-users" import { UserDetail } from "@/features/users/user-detail" import { UserListPane } from "@/features/users/user-list-pane" @@ -29,43 +31,28 @@ function filterOptions(counts: AdminUserListResponse["counts"] | undefined) { export function UsersPage({ focusId }: SectionPageProps) { const [filter, setFilter] = React.useState("all") const [query, setQuery] = React.useState("") - const debouncedQuery = useDebounced(query, SEARCH_DEBOUNCE_MS) - const [selectedId, setSelectedId] = React.useState(focusId) + const debouncedQuery = useDebounced(query) const searchTerm = userSearchTerm(debouncedQuery) const listParams: AdminUserListQuery = { ...(filter === "all" ? {} : { filter }), ...(searchTerm ? { q: searchTerm } : {}), } - const listKey = JSON.stringify(listParams) const listQuery = useUserListInfinite(listParams) const items = React.useMemo( - () => listQuery.data?.pages.flatMap((p) => p.items) ?? [], + () => flatPages(listQuery.data), [listQuery.data], ) const counts = listQuery.data?.pages[0]?.counts - React.useEffect(() => { - if (focusId) setSelectedId(focusId) - }, [focusId]) - // The selection rules every master-detail page follows: only the first load picks on the operator's - // behalf. A pick that a filter or search leaves out stays open, because the detail reads it by id. A - // pick that drops out of the same list after a refetch (a ban under the Active chip, say) clears, so - // the pane never jumps to another account's Ban button. Each decision waits for data fetched for the - // current params: a cached page that is refetching may predate the change that matters. - 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.length) setSelectedId(items[0]!.id) - return - } - setAutoPick(false) - if (items.some((x) => x.id === 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]) + const { selectedId, setSelectedId } = useSelection({ + focusId, + list: listQuery, + items, + getId: idOf, + listKey: JSON.stringify(listParams), + }) return ( <> @@ -86,16 +73,12 @@ export function UsersPage({ focusId }: SectionPageProps) { onChange={(v) => setFilter(v as UserFilter)} />
-
- - setQuery(e.target.value)} - /> -
+
diff --git a/apps/admin/src/hooks/use-admin-auth.test.tsx b/apps/admin/src/hooks/use-admin-auth.test.tsx index 4cf9507..b15d7ec 100644 --- a/apps/admin/src/hooks/use-admin-auth.test.tsx +++ b/apps/admin/src/hooks/use-admin-auth.test.tsx @@ -9,7 +9,7 @@ import { makeTestQueryClient } from "@/test/render" import { API_BASE_URL, getApiClient } from "@/lib/api" import type * as ApiModule from "@/lib/api" import { useAuthStore } from "@/store/auth-store" -import { useAdminLogout, useOperatorBootstrap, useOperatorSession } from "./use-admin-auth" +import { useOperatorLogout, useOperatorBootstrap, useOperatorSession } from "./use-admin-auth" // The real client binds window.fetch when @/lib/api first evaluates, so the stub must exist before // that import for getApiClient() to route through it; the bound mock outlives the afterEach unstub. @@ -282,7 +282,7 @@ describe("useOperatorBootstrap: Access exchange", () => { }) }) -describe("useAdminLogout", () => { +describe("useOperatorLogout", () => { let assign: ReturnType beforeEach(() => { @@ -292,7 +292,7 @@ describe("useAdminLogout", () => { }) async function logout(client: QueryClient): Promise { - const { result } = renderHook(() => useAdminLogout(), { wrapper: wrapperFor(client) }) + const { result } = renderHook(() => useOperatorLogout(), { wrapper: wrapperFor(client) }) await act(async () => { await result.current() }) @@ -328,7 +328,7 @@ describe("useAdminLogout", () => { resolveLogout = resolve }), ) - const { result } = renderHook(() => useAdminLogout(), { wrapper: wrapperFor(makeTestQueryClient()) }) + const { result } = renderHook(() => useOperatorLogout(), { wrapper: wrapperFor(makeTestQueryClient()) }) let pending: Promise = Promise.resolve() act(() => { pending = result.current() diff --git a/apps/admin/src/hooks/use-admin-auth.ts b/apps/admin/src/hooks/use-admin-auth.ts index fb1b3c6..2a25e53 100644 --- a/apps/admin/src/hooks/use-admin-auth.ts +++ b/apps/admin/src/hooks/use-admin-auth.ts @@ -105,7 +105,7 @@ export function useOperatorBootstrap(): () => Promise { * 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 { +export function useOperatorLogout(): () => Promise { const setStatus = useAuthStore((s) => s.setStatus) const clear = useAuthStore((s) => s.clear) const queryClient = useQueryClient() diff --git a/apps/admin/src/hooks/use-now.test.tsx b/apps/admin/src/hooks/use-now.test.tsx new file mode 100644 index 0000000..7b267de --- /dev/null +++ b/apps/admin/src/hooks/use-now.test.tsx @@ -0,0 +1,47 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { MINUTE_MS, SECOND_MS } from "@/lib/timing" +import { useNow } from "./use-now" + +const START = Date.parse("2026-09-20T15:00:00.000Z") + +describe("useNow", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(START) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it("starts at the current time and advances once per interval", () => { + const hook = renderHook(() => useNow()) + expect(hook.result.current).toBe(START) + + act(() => { + vi.advanceTimersByTime(MINUTE_MS - 1) + }) + expect(hook.result.current).toBe(START) + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(hook.result.current).toBe(START + MINUTE_MS) + }) + + it("honors a custom interval", () => { + const hook = renderHook(() => useNow(SECOND_MS)) + act(() => { + vi.advanceTimersByTime(SECOND_MS) + }) + expect(hook.result.current).toBe(START + SECOND_MS) + }) + + it("clears its interval on unmount", () => { + const hook = renderHook(() => useNow()) + expect(vi.getTimerCount()).toBe(1) + hook.unmount() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/apps/admin/src/hooks/use-now.ts b/apps/admin/src/hooks/use-now.ts new file mode 100644 index 0000000..05015ac --- /dev/null +++ b/apps/admin/src/hooks/use-now.ts @@ -0,0 +1,14 @@ +"use client" + +import * as React from "react" + +import { MINUTE_MS } from "@/lib/timing" + +export function useNow(intervalMs = MINUTE_MS): number { + const [now, setNow] = React.useState(() => Date.now()) + React.useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), intervalMs) + return () => clearInterval(timer) + }, [intervalMs]) + return now +} diff --git a/apps/admin/src/hooks/use-selection.test.tsx b/apps/admin/src/hooks/use-selection.test.tsx new file mode 100644 index 0000000..219b49a --- /dev/null +++ b/apps/admin/src/hooks/use-selection.test.tsx @@ -0,0 +1,182 @@ +import { act, renderHook } from "@testing-library/react" +import { describe, expect, it } from "vitest" + +import { idOf, resolveSelected, useSelection, type SelectionOptions } from "./use-selection" + +type Row = { id: string; label?: string } + +const A: Row = { id: "a" } +const B: Row = { id: "b" } +const C: Row = { id: "c" } + +const SETTLED = { isSuccess: true, isFetching: false } +const FETCHING = { isSuccess: true, isFetching: true } +const LOADING = { isSuccess: false, isFetching: true } + +type Props = Partial> + +function setup(initial: Props = {}) { + const base: SelectionOptions = { + focusId: null, + list: SETTLED, + items: [A, B], + getId: idOf, + listKey: "all", + ...initial, + } + return renderHook((props: Props) => useSelection({ ...base, ...props }), { initialProps: {} }) +} + +describe("useSelection", () => { + it("auto-picks the first row once the first load settles", () => { + const hook = setup({ list: LOADING, items: [] }) + expect(hook.result.current.selectedId).toBeNull() + + hook.rerender({ list: FETCHING, items: [A, B] }) + expect(hook.result.current.selectedId).toBeNull() + + hook.rerender({ list: SETTLED, items: [A, B] }) + expect(hook.result.current.selectedId).toBe("a") + expect(hook.result.current.selectedItem).toBe(A) + }) + + it("never auto-picks over a deep link, even when the link is not listed", () => { + const hook = setup({ focusId: "z" }) + expect(hook.result.current.selectedId).toBe("z") + expect(hook.result.current.selectedItem).toBeNull() + }) + + it("re-selects a new deep link", () => { + const hook = setup({ focusId: "a" }) + act(() => hook.result.current.setSelectedId("b")) + hook.rerender({ focusId: "c", items: [A, B, C] }) + expect(hook.result.current.selectedId).toBe("c") + }) + + it("leaves deep links to the page when syncFocus is off", () => { + const hook = setup({ focusId: "a", syncFocus: false }) + act(() => hook.result.current.setSelectedId("b")) + hook.rerender({ focusId: "c", items: [A, B, C] }) + expect(hook.result.current.selectedId).toBe("b") + }) + + it("keeps a pick that a filter or search leaves out", () => { + const hook = setup() + act(() => hook.result.current.setSelectedId("b")) + hook.rerender({ listKey: "flagged", items: [A] }) + expect(hook.result.current.selectedId).toBe("b") + }) + + it("clears a pick that drops out of the same list after a refetch, without picking the first row", () => { + const hook = setup() + act(() => hook.result.current.setSelectedId("b")) + hook.rerender({ list: FETCHING, items: [A, B] }) + expect(hook.result.current.selectedId).toBe("b") + + hook.rerender({ list: SETTLED, items: [A] }) + expect(hook.result.current.selectedId).toBeNull() + + hook.rerender({ list: SETTLED, items: [A, C] }) + expect(hook.result.current.selectedId).toBeNull() + }) + + it("does not judge a drop-out on a list still refetching", () => { + const hook = setup() + act(() => hook.result.current.setSelectedId("b")) + hook.rerender({ list: FETCHING, items: [A] }) + expect(hook.result.current.selectedId).toBe("b") + }) + + it("keeps a deep link that drops out of another list before it was ever listed", () => { + const hook = setup({ focusId: "z", items: [A] }) + hook.rerender({ listKey: "flagged", items: [B] }) + expect(hook.result.current.selectedId).toBe("z") + }) + + describe("readableById: false", () => { + it("clears a pick that a filter leaves out", () => { + const hook = setup({ readableById: false }) + act(() => hook.result.current.setSelectedId("b")) + hook.rerender({ listKey: "suspended", items: [A] }) + expect(hook.result.current.selectedId).toBeNull() + }) + + it("keeps the unlisted deep link open", () => { + const hook = setup({ readableById: false, focusId: "z" }) + hook.rerender({ listKey: "suspended", items: [A] }) + expect(hook.result.current.selectedId).toBe("z") + }) + }) + + it("clearIfSelected clears only the matching pick", () => { + const hook = setup() + expect(hook.result.current.selectedId).toBe("a") + act(() => hook.result.current.clearIfSelected("b")) + expect(hook.result.current.selectedId).toBe("a") + act(() => hook.result.current.clearIfSelected("a")) + expect(hook.result.current.selectedId).toBeNull() + }) + + it("restartAutoPick picks the first row again after a fresh load", () => { + const hook = setup() + act(() => hook.result.current.setSelectedId(null)) + expect(hook.result.current.selectedId).toBeNull() + act(() => hook.result.current.restartAutoPick()) + expect(hook.result.current.selectedId).toBe("a") + }) + + describe("unreadIds", () => { + it("keeps a message that opening it read out of the same unread list", () => { + const hook = setup({ items: [A, B], unreadIds: new Set(["a", "b"]) }) + act(() => { + hook.result.current.setSelectedId("b") + hook.result.current.readOnOpen.current = "b" + }) + hook.rerender({ items: [A], unreadIds: new Set(["a"]) }) + expect(hook.result.current.selectedId).toBe("b") + expect(hook.result.current.readOnOpen.current).toBeNull() + }) + + it("clears it once the list has shown it read and it then drops out", () => { + const hook = setup({ items: [A, B], unreadIds: new Set(["a", "b"]) }) + act(() => { + hook.result.current.setSelectedId("b") + hook.result.current.readOnOpen.current = "b" + }) + hook.rerender({ items: [A, B], unreadIds: new Set(["a"]) }) + expect(hook.result.current.readOnOpen.current).toBeNull() + + hook.rerender({ items: [A], unreadIds: new Set(["a"]) }) + expect(hook.result.current.selectedId).toBeNull() + }) + }) + + describe("keepLastSeen", () => { + it("keeps showing the last listed row for a pick a filter leaves out", () => { + const hook = setup({ keepLastSeen: true }) + act(() => hook.result.current.setSelectedId("b")) + expect(hook.result.current.selectedItem).toBe(B) + hook.rerender({ listKey: "routed", items: [A] }) + expect(hook.result.current.selectedId).toBe("b") + expect(hook.result.current.selectedItem).toBe(B) + }) + + it("resolves only listed rows without it", () => { + const hook = setup() + act(() => hook.result.current.setSelectedId("b")) + hook.rerender({ listKey: "routed", items: [A] }) + expect(hook.result.current.selectedId).toBe("b") + expect(hook.result.current.selectedItem).toBeNull() + }) + }) +}) + +describe("resolveSelected", () => { + it("prefers the listed row, falls back to a matching last-seen row, and ignores a stale one", () => { + const listedB = { id: "b", label: "fresh" } + expect(resolveSelected([A, listedB], "b", B, idOf)).toBe(listedB) + expect(resolveSelected([A], "b", B, idOf)).toBe(B) + expect(resolveSelected([A], "b", C, idOf)).toBeNull() + expect(resolveSelected([A], null, A, idOf)).toBeNull() + }) +}) diff --git a/apps/admin/src/hooks/use-selection.ts b/apps/admin/src/hooks/use-selection.ts new file mode 100644 index 0000000..475abcd --- /dev/null +++ b/apps/admin/src/hooks/use-selection.ts @@ -0,0 +1,125 @@ +"use client" + +import * as React from "react" + +interface SelectionListState { + isSuccess: boolean + isFetching: boolean +} + +export interface SelectionOptions { + /** The deep-linked id: the initial pick, and it suppresses the first-load auto-pick. */ + focusId: Id | null + /** False when the page applies deep links itself because they move other state with the pick. */ + syncFocus?: boolean + list: SelectionListState + items: readonly T[] + /** Keep it module-level: it is an effect dependency. */ + getId: (item: T) => Id + /** Identifies the params the list was fetched with, so a drop-out is judged against the same list. */ + listKey: string + /** False when the detail has no by-id read: any unlisted pick clears except the deep link. */ + readableById?: boolean + /** + * Opening an unread row reads it, which is not the operator's action: while the returned `readOnOpen` + * holds the pick, dropping out of an unread-only list counts as a filter drop-out until the list + * shows it read. + */ + unreadIds?: ReadonlySet + /** Keep showing the last listed row for a pick that a filter or search leaves out. */ + keepLastSeen?: boolean +} + +export function idOf(item: T): string { + return item.id +} + +export function resolveSelected( + items: readonly T[], + selectedId: Id | null, + lastSeen: T | null, + getId: (item: T) => Id, +): T | null { + if (selectedId === null) return null + return ( + items.find((item) => getId(item) === selectedId) ?? + (lastSeen !== null && getId(lastSeen) === selectedId ? lastSeen : null) + ) +} + +// Every master-detail page follows one rule. Only a first load with no deep link picks on the +// operator's behalf, and a deep link always wins. A pick that a filter or search leaves out stays open +// (the detail reads it by id); a pick that drops out of the SAME list after a refetch (a ban under the +// Active chip, a verdict under Needs verification) clears, so the pane never jumps to another row's live +// action buttons and is never replaced by the first row. Each decision waits for data fetched for the +// current params: a cached page that is refetching may predate the change that matters. +export function useSelection({ + focusId, + syncFocus = true, + list, + items, + getId, + listKey, + readableById = true, + unreadIds, + keepLastSeen = false, +}: SelectionOptions) { + const [selectedId, setSelectedId] = React.useState(focusId) + const [autoPick, setAutoPick] = React.useState(focusId === null) + const [lastSeen, setLastSeen] = React.useState(null) + const seenIn = React.useRef<{ id: Id; list: string } | null>(null) + const readOnOpen = React.useRef(null) + + React.useEffect(() => { + if (syncFocus && focusId) setSelectedId(focusId) + }, [syncFocus, focusId]) + + const selectedItem = resolveSelected(items, selectedId, keepLastSeen ? lastSeen : null, getId) + React.useEffect(() => { + if (keepLastSeen && selectedItem && selectedItem !== lastSeen) setLastSeen(selectedItem) + }, [keepLastSeen, selectedItem, lastSeen]) + + const { isSuccess, isFetching } = list + const linkedId = readableById ? null : focusId + React.useEffect(() => { + if (!isSuccess || isFetching) return + if (selectedId === null) { + if (autoPick && items.length) setSelectedId(getId(items[0]!)) + return + } + setAutoPick(false) + if (items.some((item) => getId(item) === selectedId)) { + seenIn.current = { id: selectedId, list: listKey } + if (readOnOpen.current === selectedId && !unreadIds?.has(selectedId)) readOnOpen.current = null + } else if ( + (!readableById && selectedId !== linkedId) || + (seenIn.current?.id === selectedId && seenIn.current.list === listKey) + ) { + if (readOnOpen.current === selectedId) { + readOnOpen.current = null + seenIn.current = null + } else { + setSelectedId(null) + } + } + }, [ + isSuccess, + isFetching, + items, + getId, + unreadIds, + selectedId, + listKey, + autoPick, + readableById, + linkedId, + ]) + + const clearIfSelected = React.useCallback( + (id: Id) => setSelectedId((current) => (current === id ? null : current)), + [], + ) + const restartAutoPick = React.useCallback(() => setAutoPick(true), []) + + return { selectedId, setSelectedId, selectedItem, clearIfSelected, restartAutoPick, readOnOpen } +} diff --git a/apps/admin/src/lib/dates.test.ts b/apps/admin/src/lib/dates.test.ts index 426be01..e1192f1 100644 --- a/apps/admin/src/lib/dates.test.ts +++ b/apps/admin/src/lib/dates.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { formatDate, formatDateTime } from "./dates" +import { formatDate, formatDateTime, formatMonthDay, formatPreciseDateTime } from "./dates" import { EMPTY_VALUE } from "./empty-value" describe("formatDate", () => { @@ -38,3 +38,31 @@ describe("formatDateTime", () => { expect(formatDateTime(value)).toBe(value) }) }) + +describe("formatMonthDay", () => { + it.each([null, undefined, ""])("renders the placeholder for %j", (value) => { + expect(formatMonthDay(value)).toBe(EMPTY_VALUE) + }) + + it("formats a short local month and day", () => { + expect(formatMonthDay("2026-09-23T14:05:00.000Z")).toBe("Sep 23") + }) + + it("returns an unparseable string unchanged", () => { + expect(formatMonthDay("not a date")).toBe("not a date") + }) +}) + +describe("formatPreciseDateTime", () => { + it.each([null, undefined, ""])("renders the placeholder for %j", (value) => { + expect(formatPreciseDateTime(value)).toBe(EMPTY_VALUE) + }) + + it("keeps the seconds", () => { + expect(formatPreciseDateTime("2026-09-23T14:05:07.000Z")).toBe("Sep 23, 2026, 2:05:07 PM") + }) + + it("returns an unparseable string unchanged", () => { + expect(formatPreciseDateTime("not a date")).toBe("not a date") + }) +}) diff --git a/apps/admin/src/lib/dates.ts b/apps/admin/src/lib/dates.ts index 7be6eaf..bd6620d 100644 --- a/apps/admin/src/lib/dates.ts +++ b/apps/admin/src/lib/dates.ts @@ -1,11 +1,23 @@ import { EMPTY_VALUE } from "./empty-value" -const DATE_PARTS: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" } +const MONTH_DAY_PARTS: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" } +const DATE_PARTS: Intl.DateTimeFormatOptions = { ...MONTH_DAY_PARTS, year: "numeric" } const DATE_TIME_PARTS: Intl.DateTimeFormatOptions = { ...DATE_PARTS, hour: "numeric", minute: "2-digit", } +const PRECISE_DATE_TIME_PARTS: Intl.DateTimeFormatOptions = { + ...DATE_TIME_PARTS, + second: "2-digit", +} + +// Date#toLocale*String with options builds a fresh Intl.DateTimeFormat on every call (about 18x the +// cost of reusing one), and these run once per row on every list render. +const MONTH_DAY_FORMAT = new Intl.DateTimeFormat(undefined, MONTH_DAY_PARTS) +const DATE_FORMAT = new Intl.DateTimeFormat(undefined, DATE_PARTS) +const DATE_TIME_FORMAT = new Intl.DateTimeFormat(undefined, DATE_TIME_PARTS) +const PRECISE_DATE_TIME_FORMAT = new Intl.DateTimeFormat(undefined, PRECISE_DATE_TIME_PARTS) // An unparseable value is shown as sent rather than hidden, so an operator still sees what arrived. function formatWith( @@ -18,10 +30,19 @@ function formatWith( return format(date) } +export function formatMonthDay(value: string | null | undefined): string { + return formatWith(value, (date) => MONTH_DAY_FORMAT.format(date)) +} + export function formatDate(value: string | null | undefined): string { - return formatWith(value, (date) => date.toLocaleDateString(undefined, DATE_PARTS)) + return formatWith(value, (date) => DATE_FORMAT.format(date)) } export function formatDateTime(value: string | null | undefined): string { - return formatWith(value, (date) => date.toLocaleString(undefined, DATE_TIME_PARTS)) + return formatWith(value, (date) => DATE_TIME_FORMAT.format(date)) +} + +// Chat and mail timestamps keep seconds: operators order and match individual messages by them. +export function formatPreciseDateTime(value: string | null | undefined): string { + return formatWith(value, (date) => PRECISE_DATE_TIME_FORMAT.format(date)) } diff --git a/apps/admin/src/lib/display.test.ts b/apps/admin/src/lib/display.test.ts new file mode 100644 index 0000000..d302a0d --- /dev/null +++ b/apps/admin/src/lib/display.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest" + +import { firstName, formatCompactCount, initials, shortId, shortRef } from "./display" + +describe("initials", () => { + it.each([ + ["Ada Lovelace", "AL"], + ["Mary Ann Smith", "MA"], + ["ada", "A"], + ["Ada Lovelace", "AL"], + [" Ada Lovelace ", "AL"], + ["Élodie Ñúñez", "ÉÑ"], + ])("takes the first letter of up to two words of %j", (name, expected) => { + expect(initials(name)).toBe(expected) + }) + + it("keeps a leading emoji whole instead of splitting its surrogate pair", () => { + expect(initials("😀 Smile")).toBe("😀S") + }) + + it.each(["", " "])("falls back to a question mark for the blank name %j", (name) => { + expect(initials(name)).toBe("?") + }) +}) + +describe("firstName", () => { + it.each([ + ["Ada Lovelace", "Ada"], + ["Ada", "Ada"], + [" Ada Lovelace", "Ada"], + ["", ""], + ])("reads %j as %j", (name, expected) => { + expect(firstName(name)).toBe(expected) + }) +}) + +describe("short ids", () => { + const uuid = "3f2b8c1e-9a4d-4c2e-8f1a-0b1c2d3e4f5a" + + it("shows the first eight characters, as server mail subjects quote them", () => { + expect(shortId(uuid)).toBe("3f2b8c1e") + expect(shortRef(uuid)).toBe("#3f2b8c1e") + }) + + it("keeps a short id whole", () => { + expect(shortRef("0f3e")).toBe("#0f3e") + }) +}) + +describe("formatCompactCount", () => { + it.each([ + [0, "0"], + [950, "950"], + [1_000, "1K"], + [1_250, "1.3K"], + [9_950, "10K"], + [138_699, "138.7K"], + [3_900_000, "3.9M"], + ])("formats %d as %j", (value, expected) => { + expect(formatCompactCount(value)).toBe(expected) + }) +}) diff --git a/apps/admin/src/lib/display.ts b/apps/admin/src/lib/display.ts index 501dd50..804c865 100644 --- a/apps/admin/src/lib/display.ts +++ b/apps/admin/src/lib/display.ts @@ -1,5 +1,44 @@ // The prefix of a UUID shown as a short reference; one length everywhere, so the same entity reads // the same on every surface. -export const SHORT_ID_LENGTH = 8 +const SHORT_ID_LENGTH = 8 -export const INITIALS_MAX_LETTERS = 2 +const INITIALS_MAX_LETTERS = 2 + +const WHITESPACE = /\s+/ + +const COMPACT_COUNT_FORMAT = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, +}) + +// A plain prefix, never dash-stripped: server mail subjects quote `id.slice(0, 8)`, and an operator +// matches the two by eye. +export function shortId(id: string): string { + return id.slice(0, SHORT_ID_LENGTH) +} + +export function shortRef(id: string): string { + return `#${shortId(id)}` +} + +export function firstName(name: string): string { + const [first = ""] = name.trim().split(WHITESPACE) + return first +} + +// Code points rather than UTF-16 units, so a name that starts with an emoji keeps the whole glyph. +export function initials(name: string): string { + const letters = name + .trim() + .split(WHITESPACE) + .filter(Boolean) + .slice(0, INITIALS_MAX_LETTERS) + .map((word) => [...word][0]) + .join("") + .toUpperCase() + return letters || "?" +} + +export function formatCompactCount(value: number): string { + return COMPACT_COUNT_FORMAT.format(value) +} diff --git a/apps/admin/src/lib/event-status.test.ts b/apps/admin/src/lib/event-status.test.ts index d6f5245..e1e19b2 100644 --- a/apps/admin/src/lib/event-status.test.ts +++ b/apps/admin/src/lib/event-status.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import { EVENT_STATUS_LABELS, type EventStatus } from "@civfix/shared" -import { cancelBlockedFor, EVENT_STATUS_VIEW, eventStatusView } from "./event-status" +import { cancelBlockedMessage, EVENT_STATUS_VIEW, eventStatusView } from "./event-status" describe("event status pills", () => { it("renders every label from the shared contract map rather than a local fork", () => { @@ -33,17 +33,17 @@ describe("event status pills", () => { describe("cancel availability", () => { it("blocks cancelling an event the clock has already ended, matching the API's 409", () => { - expect(cancelBlockedFor("completed")).toBe( + expect(cancelBlockedMessage("completed")).toBe( "This event has already ended and can't be cancelled.", ) }) it("blocks cancelling an event that is already cancelled", () => { - expect(cancelBlockedFor("cancelled")).toBe("This event is already cancelled.") + expect(cancelBlockedMessage("cancelled")).toBe("This event is already cancelled.") }) it("allows cancelling while the event is still upcoming or running", () => { - expect(cancelBlockedFor("upcoming")).toBeNull() - expect(cancelBlockedFor("in_progress")).toBeNull() + expect(cancelBlockedMessage("upcoming")).toBeNull() + expect(cancelBlockedMessage("in_progress")).toBeNull() }) }) diff --git a/apps/admin/src/lib/event-status.ts b/apps/admin/src/lib/event-status.ts index f1253bd..572400f 100644 --- a/apps/admin/src/lib/event-status.ts +++ b/apps/admin/src/lib/event-status.ts @@ -15,7 +15,7 @@ export function eventStatusView(status: EventStatus): { cls: string; label: stri : { cls: "priority-low", label: status } } -export function cancelBlockedFor(status: EventStatus): string | null { +export function cancelBlockedMessage(status: EventStatus): string | null { if (status === "completed") return "This event has already ended and can't be cancelled." if (status === "cancelled") return "This event is already cancelled." return null diff --git a/apps/admin/src/lib/external-url.test.ts b/apps/admin/src/lib/external-url.test.ts index 2db8761..a50944b 100644 --- a/apps/admin/src/lib/external-url.test.ts +++ b/apps/admin/src/lib/external-url.test.ts @@ -16,6 +16,10 @@ describe("external url guard", () => { expect(isHttpsUrl("data:text/html,")).toBe(false) }) + it("still requires a parseable url", () => { + expect(isHttpsUrl("https://example.org:99999/")).toBe(false) + }) + it("treats a missing url as unsafe", () => { expect(isHttpsUrl(null)).toBe(false) expect(isHttpsUrl(undefined)).toBe(false) diff --git a/apps/admin/src/lib/infinite.ts b/apps/admin/src/lib/infinite.ts new file mode 100644 index 0000000..928b653 --- /dev/null +++ b/apps/admin/src/lib/infinite.ts @@ -0,0 +1,34 @@ +import { infiniteQueryOptions, type QueryKey } from "@tanstack/react-query" + +export interface CursorPage { + nextCursor?: string | null +} + +/** + * A cursor-paged list: the first page is fetched with `params` alone, each later page with the + * previous page's `nextCursor` as `cursor`, and a null cursor ends the list. + */ +export function infiniteListOptions< + TKey extends QueryKey, + TParams extends object, + TPage extends CursorPage, +>( + queryKey: TKey, + params: TParams, + fetchPage: (input: TParams & { cursor?: string }) => Promise, +) { + return infiniteQueryOptions({ + queryKey, + queryFn: ({ pageParam }) => + fetchPage({ + ...params, + ...(typeof pageParam === "string" ? { cursor: pageParam } : {}), + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }) +} + +export function flatPages(data: { pages: readonly { items: readonly T[] }[] } | undefined): T[] { + return data?.pages.flatMap((p) => p.items) ?? [] +} diff --git a/apps/admin/src/lib/query.test.ts b/apps/admin/src/lib/query.test.ts index c7da1e1..32a9bbe 100644 --- a/apps/admin/src/lib/query.test.ts +++ b/apps/admin/src/lib/query.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest" -import { MutationObserver, type QueryClient } from "@tanstack/react-query" +import { MutationObserver, QueryObserver, type QueryClient } from "@tanstack/react-query" import { AppError, ErrorCode } from "@civfix/shared" -import { makeQueryClient, queryKeys } from "./query" +import { invalidateKeys, makeQueryClient, queryKeys } from "./query" import { useUiStore } from "@/store/ui-store" function foreignAppError(code: ErrorCode, message: string): Error { @@ -394,3 +394,61 @@ describe("page vs list keys", () => { } }) }) + +describe("invalidateKeys", () => { + async function watched(client: QueryClient, queryKey: readonly unknown[]) { + const queryFn = vi.fn(async () => ({ ok: true })) + const observer = new QueryObserver(client, { queryKey, queryFn }) + const unsubscribe = observer.subscribe(() => undefined) + await vi.waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(observer.getCurrentResult().isSuccess).toBe(true)) + queryFn.mockClear() + return { queryFn, unsubscribe } + } + + it("refetches a query once when a broader key in the batch also matches it", async () => { + const client = makeQueryClient() + const detail = await watched(client, queryKeys.events.detail("e1")) + const list = await watched(client, queryKeys.events.list({})) + + await invalidateKeys(client, [queryKeys.events.detail("e1"), queryKeys.events.all, null]) + + expect(detail.queryFn).toHaveBeenCalledTimes(1) + expect(list.queryFn).toHaveBeenCalledTimes(1) + detail.unsubscribe() + list.unsubscribe() + }) + + it("refetches a query once when the same key is listed twice", async () => { + const client = makeQueryClient() + const stats = await watched(client, queryKeys.mail.stats) + + await invalidateKeys(client, [queryKeys.mail.stats, queryKeys.mail.stats]) + + expect(stats.queryFn).toHaveBeenCalledTimes(1) + stats.unsubscribe() + }) + + it("never skips a key with object params, whose matching is not transitive", async () => { + const client = makeQueryClient() + const flagged = await watched(client, queryKeys.events.list({ filter: "flagged" })) + + await invalidateKeys(client, [ + queryKeys.events.list({ filter: undefined }), + queryKeys.events.list({}), + ]) + + expect(flagged.queryFn).toHaveBeenCalledTimes(1) + flagged.unsubscribe() + }) + + it("still invalidates a key that no other key in the batch covers", async () => { + const client = makeQueryClient() + const detail = await watched(client, queryKeys.reports.detail("r1")) + + await invalidateKeys(client, [queryKeys.reports.detail("r1"), queryKeys.events.all]) + + expect(detail.queryFn).toHaveBeenCalledTimes(1) + detail.unsubscribe() + }) +}) diff --git a/apps/admin/src/lib/query.ts b/apps/admin/src/lib/query.ts index 0a985df..113417d 100644 --- a/apps/admin/src/lib/query.ts +++ b/apps/admin/src/lib/query.ts @@ -1,6 +1,12 @@ "use client" -import { MutationCache, QueryClient } from "@tanstack/react-query" +import { + MutationCache, + QueryClient, + partialMatchKey, + type InvalidateQueryFilters, + type QueryKey, +} from "@tanstack/react-query" import { ErrorCode, type AdminBroadcastListQuery, @@ -231,3 +237,48 @@ export const queryKeys = { list: (params?: AuditListQuery) => ["admin", "audit", "list", params ?? null] as const, }, } + +type Invalidation = QueryKey | InvalidateQueryFilters | null + +function isQueryKey(target: QueryKey | InvalidateQueryFilters): target is QueryKey { + return Array.isArray(target) +} + +// A second invalidation of a query whose refetch is in flight cancels that refetch and requests it +// again, so a key that a broader key in the same batch already matches (or an earlier copy of itself) +// would cost a duplicate request for no fresher data. +// Only keys of primitives compose: partialMatchKey is not transitive for object segments (an explicit +// undefined property matches differently than a missing one), so such a key is never skipped. +const isPrimitiveKey = (key: QueryKey): boolean => + key.every((part) => part === null || typeof part !== "object") + +function coveredByAnother(target: QueryKey, index: number, targets: readonly Invalidation[]): boolean { + if (!isPrimitiveKey(target)) return false + return targets.some( + (other, otherIndex) => + otherIndex !== index && + other !== null && + isQueryKey(other) && + isPrimitiveKey(other) && + partialMatchKey(target, other) && + (!partialMatchKey(other, target) || otherIndex < index), + ) +} + +/** + * Invalidates each target in order (a null entry is skipped) and settles once every refetch has, so + * a mutation that returns or awaits it stays pending until the screens it changed show fresh data. + */ +export function invalidateKeys( + qc: QueryClient, + targets: readonly Invalidation[], +): Promise { + return Promise.all( + targets.map((target, index) => { + if (target === null) return null + if (!isQueryKey(target)) return qc.invalidateQueries(target) + if (coveredByAnother(target, index, targets)) return null + return qc.invalidateQueries({ queryKey: target }) + }), + ) +} diff --git a/apps/admin/src/lib/report-status.test.ts b/apps/admin/src/lib/report-status.test.ts index d1d8833..e02a4b5 100644 --- a/apps/admin/src/lib/report-status.test.ts +++ b/apps/admin/src/lib/report-status.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { isReportStatus, - reportBucketOf, + reportBucket, reportNeedsAttention, reportStatusView, } from "./report-status" @@ -16,10 +16,10 @@ describe("report status buckets", () => { }) it("buckets an unknown status as awaiting action", () => { - expect(reportBucketOf("published")).toBe("submitted") - expect(reportBucketOf("in_progress")).toBe("in_progress") - expect(reportBucketOf("resolved")).toBe("completed") - expect(reportBucketOf("upcoming")).toBe("submitted") + expect(reportBucket("published")).toBe("submitted") + expect(reportBucket("in_progress")).toBe("in_progress") + expect(reportBucket("resolved")).toBe("completed") + expect(reportBucket("upcoming")).toBe("submitted") }) it("needs attention for flagged pins and the awaiting-action bucket only", () => { diff --git a/apps/admin/src/lib/report-status.ts b/apps/admin/src/lib/report-status.ts index 7bd73fd..cc849c7 100644 --- a/apps/admin/src/lib/report-status.ts +++ b/apps/admin/src/lib/report-status.ts @@ -45,7 +45,7 @@ export function isReportStatus(status: string): status is AdminReportStatus { } // A status from a newer server reads as awaiting action rather than crashing or passing as Completed. -export function reportBucketOf(status: string): ReportBucket { +export function reportBucket(status: string): ReportBucket { return isReportStatus(status) ? REPORT_STATUS_BUCKET[status] : "submitted" } @@ -55,5 +55,5 @@ export function reportNeedsAttention(status: string, flagged: boolean): boolean // Takes any string because the home map's pin status also spans event statuses. export function reportStatusView(status: string): ReportStatusView { - return BUCKET_VIEW[reportBucketOf(status)] + return BUCKET_VIEW[reportBucket(status)] } diff --git a/apps/admin/src/styles/app.css b/apps/admin/src/styles/app.css index 5958c32..a51d35d 100644 --- a/apps/admin/src/styles/app.css +++ b/apps/admin/src/styles/app.css @@ -333,6 +333,10 @@ width: calc(100% - 2 * var(--space-3)); margin: var(--space-2) var(--space-3); } +.btn.list-load-more { + width: calc(100% - 20px); + margin: var(--space-2) 10px; +} .qrow.static { cursor: default; } .qrow.static:hover { background: transparent; } diff --git a/apps/admin/tailwind.config.ts b/apps/admin/tailwind.config.ts index 7f5e795..e74d4bd 100644 --- a/apps/admin/tailwind.config.ts +++ b/apps/admin/tailwind.config.ts @@ -2,16 +2,10 @@ import type { Config } from "tailwindcss" import { tokens } from "@civfix/shared/tokens" /** - * Tailwind theme derived entirely from the shared design tokens. - * - * The admin dashboard's *visual* source of truth is the ported design stylesheet - * (src/styles/admin.css + src/styles/colors-and-type.css), which owns the warm-paper CSS custom - * properties (--bloom, --ink, --paper, --cat-*, ...) and every component class (.card, .stile, - * .qrow, .btn, ...). Tailwind here is available for incidental LAYOUT utilities only; it never needs - * to redefine those component classes. To keep the civfix rule "never hardcode a hex the design - * system covers", this config maps every shared token scale into Tailwind exactly like community-web, - * so any utility we do reach for (bg-paper, text-ink3, rounded-lg, shadow-s2, font-display, ...) draws - * from the same single source of truth the design CSS does. + * Tailwind supplies only its base reset (preflight) here: the markup uses no intended Tailwind utilities, + * because the ported design stylesheet (src/styles/admin.css + colors-and-type.css) owns every component class + * and CSS variable. The theme still maps the shared design tokens so any utility added later (bg-paper, + * text-ink-3, rounded-lg, shadow-s2, font-display, ...) draws from the same source as community-web. */ const { color, font, fontSize, radius, shadow, space } = tokens @@ -22,7 +16,7 @@ const px = (n: number): string => `${n}px` const config: Config = { // Class-based dark mode is left available but the warm palette is light-first. darkMode: ["class"], - content: ["./src/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"], + content: ["./src/**/*.{ts,tsx}"], theme: { container: { center: true, @@ -126,7 +120,7 @@ const config: Config = { }, }, }, - plugins: [require("tailwindcss-animate")], + plugins: [], } export default config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05aa8eb..aea1c54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,15 +30,9 @@ importers: '@tanstack/react-query': specifier: ^5.62.7 version: 5.101.0(react@19.0.0) - clsx: - specifier: ^2.1.1 - version: 2.1.1 leaflet: specifier: ^1.9.4 version: 1.9.4 - lucide-react: - specifier: ^0.469.0 - version: 0.469.0(react@19.0.0) next: specifier: 15.5.25 version: 15.5.25(@types/node@22.19.20)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -48,19 +42,19 @@ importers: react-dom: specifier: 19.0.0 version: 19.0.0(react@19.0.0) - tailwind-merge: - specifier: ^2.6.0 - version: 2.6.1 zustand: specifier: ^5.0.2 version: 5.0.14(@types/react@19.2.17)(react@19.0.0) devDependencies: + '@eslint/eslintrc': + specifier: ^3.3.5 + version: 3.3.5 '@testing-library/dom': specifier: ^10.4.2 version: 10.4.2 '@testing-library/jest-dom': specifier: ^7.0.1 - version: 7.0.1(@testing-library/dom@10.4.2)(vitest@3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0)) + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0)(yaml@2.9.1)) '@testing-library/react': specifier: ^16.3.3 version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -88,9 +82,15 @@ importers: eslint-config-next: specifier: 15.5.25 version: 15.5.25(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + jscpd: + specifier: ^5.3.2 + version: 5.3.2 jsdom: specifier: ^26.1.0 version: 26.1.0 + knip: + specifier: ^6.38.0 + version: 6.38.0 postcss: specifier: '>=8.5.28 <9' version: 8.5.28 @@ -99,16 +99,13 @@ importers: version: 6.1.3 tailwindcss: specifier: ^3.4.17 - version: 3.4.19 - tailwindcss-animate: - specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.19) + version: 3.4.19(yaml@2.9.1) typescript: specifier: ^5.7.2 version: 5.9.3 vitest: specifier: ^3.2.7 - version: 3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0) + version: 3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0)(yaml@2.9.1) packages: @@ -169,15 +166,24 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -557,6 +563,13 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@next/env@15.5.25': resolution: {integrity: sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==} @@ -627,6 +640,218 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-parser/binding-android-arm-eabi@0.150.0': + resolution: {integrity: sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.150.0': + resolution: {integrity: sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.150.0': + resolution: {integrity: sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.150.0': + resolution: {integrity: sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.150.0': + resolution: {integrity: sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': + resolution: {integrity: sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': + resolution: {integrity: sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': + resolution: {integrity: sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.150.0': + resolution: {integrity: sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': + resolution: {integrity: sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': + resolution: {integrity: sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': + resolution: {integrity: sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': + resolution: {integrity: sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.150.0': + resolution: {integrity: sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.150.0': + resolution: {integrity: sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-openharmony-arm64@0.150.0': + resolution: {integrity: sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': + resolution: {integrity: sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': + resolution: {integrity: sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.150.0': + resolution: {integrity: sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@rollup/rollup-android-arm-eabi@4.61.1': resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} cpu: [arm] @@ -837,6 +1062,9 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1262,10 +1490,6 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1586,6 +1810,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1618,6 +1845,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + formatly@0.7.1: + resolution: {integrity: sha512-ESuCEJE0SGgabl9q+zABI8LhJDmF+8nhpuqDzuM48HfJ9QTD5o6PqFJDFZY7w6f8xsh5lixuj5nJsQNvYOWvKQ==} + engines: {node: '>=18.3.0'} + hasBin: true + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1655,6 +1887,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1870,6 +2105,10 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1880,6 +2119,51 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jscpd-darwin-arm64@5.3.2: + resolution: {integrity: sha512-dlNh+revPs3X1yTRNAYqZD9u7eqiR9I+KoNvEk9JzSwzOuaUQrgwpP5EZQB0NKuF0oZZZeKrk05OTsNorVpqmQ==} + cpu: [arm64] + os: [darwin] + + jscpd-darwin-x64@5.3.2: + resolution: {integrity: sha512-JUt9dhLw2k1WNLqbFcqbDf8ErR6mp9ucKn+chRBsBRdx7MNbdEXmV5UuHRBO8d9h7Cw1eSpfpBPMTCXOS4aaeA==} + cpu: [x64] + os: [darwin] + + jscpd-linux-arm64-gnu@5.3.2: + resolution: {integrity: sha512-4+Z/fW7nqruf47AXRXPe8nTeExPm93Gt4A/6B4Wsi6b0G8Ei/tYDxB4jAwUBXnOGVwxrr/IDJlgSEvHLbzX+MQ==} + cpu: [arm64] + os: [linux] + + jscpd-linux-arm64-musl@5.3.2: + resolution: {integrity: sha512-+OsVA0iLfDNUIzJArAaJnGyOHxsflpbpkGh/mWs2nZV7vPd1g5Y9SWO+vj83f6BdaO9VNbUbaQAY4dBYadfHIA==} + cpu: [arm64] + os: [linux] + + jscpd-linux-x64-gnu@5.3.2: + resolution: {integrity: sha512-DsNsbCCc5mMOGZdhzgPETsp9RAaKk03cdx1K9wx9RShePnF+AXstYYSiW7M/qjZqvBMlSpO60BXpJvNUQljJJg==} + cpu: [x64] + os: [linux] + + jscpd-linux-x64-musl@5.3.2: + resolution: {integrity: sha512-9D+Jg4MXTHoY5FiK6Fh3FeAr1MsqNS98X5zMUH6PNXu9PKsT0g3Rez2vdYOnoKOE4n8o1XAs8eyU0pm3+HYwog==} + cpu: [x64] + os: [linux] + + jscpd-windows-arm64-msvc@5.3.2: + resolution: {integrity: sha512-qPqZEMHUOjc5tKVU7PElyIhI1J0xyAuQ44S2SwLvKS5qw1NATGTaGhp8MuJkrvTmNcNZmLF0Me6Cv6zj6DVlWA==} + cpu: [arm64] + os: [win32] + + jscpd-windows-x64-msvc@5.3.2: + resolution: {integrity: sha512-WfO1k0/NXA+2HjLaWUkheQv6bEkYrI4WKa/Cf429+dnpdiYB6S8KpJyd6/xGJd+XJ9nKhE0mIFuk3+VdXtaFMQ==} + cpu: [x64] + os: [win32] + + jscpd@5.3.2: + resolution: {integrity: sha512-siYdg3TBNwizWFaYPgr1vwuSs+NWeKJjLAbd00Nkmr2y8TSZRSW0cOGKtqSLgOv7QXDbC/XECnCrZEm5v6RMkg==} + engines: {node: '>=18'} + hasBin: true + jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -1909,6 +2193,11 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + knip@6.38.0: + resolution: {integrity: sha512-umytiMCaZf02MV9SOO1FEDgbDf6h20VZk3ruJE0kh52kkGjYZFRUAXHJWYoLhvhoHGINO/7GXVJmQ2vxTBvl6A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -1951,11 +2240,6 @@ packages: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} - lucide-react@0.469.0: - resolution: {integrity: sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2092,6 +2376,13 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + oxc-parser@0.150.0: + resolution: {integrity: sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -2103,6 +2394,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2143,6 +2437,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -2375,6 +2673,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2427,6 +2729,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -2459,14 +2765,6 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tailwind-merge@2.6.1: - resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} - - tailwindcss-animate@1.0.7: - resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} - peerDependencies: - tailwindcss: '>=3.0.0 || insiders' - tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} @@ -2564,6 +2862,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + unbash@4.0.11: + resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} + engines: {node: '>=14'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -2663,6 +2965,10 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -2729,6 +3035,11 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2736,6 +3047,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zustand@5.0.14: resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} engines: {node: '>=12.20.0'} @@ -2808,11 +3122,22 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 @@ -2823,6 +3148,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -3091,6 +3421,13 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.4 + optional: true + '@next/env@15.5.25': {} '@next/eslint-plugin-next@15.5.25': @@ -3135,6 +3472,126 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-parser/binding-android-arm-eabi@0.150.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.150.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.150.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.150.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.150.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.150.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.150.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.150.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.150.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.150.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.150.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.150.0': + optional: true + + '@oxc-project/types@0.150.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@rollup/rollup-android-arm-eabi@4.61.1': optional: true @@ -3236,7 +3693,7 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0))': + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0)(yaml@2.9.1))': dependencies: '@adobe/css-tools': 4.5.0 '@testing-library/dom': 10.4.2 @@ -3246,7 +3703,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0) + vitest: 3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0)(yaml@2.9.1) '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: @@ -3285,6 +3742,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/chai@5.2.3': @@ -3487,13 +3949,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.19.20)(jiti@1.21.7))': + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.19.20)(jiti@1.21.7)(yaml@2.9.1))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@22.19.20)(jiti@1.21.7) + vite: 7.3.6(@types/node@22.19.20)(jiti@1.21.7)(yaml@2.9.1) '@vitest/pretty-format@3.2.7': dependencies: @@ -3736,8 +4198,6 @@ snapshots: client-only@0.0.1: {} - clsx@2.1.1: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4211,10 +4671,18 @@ snapshots: dependencies: reusify: 1.1.0 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -4239,6 +4707,11 @@ snapshots: dependencies: is-callable: 1.2.7 + formatly@0.7.1: + dependencies: + fd-package-json: 2.0.0 + package-manager-detector: 1.8.0 + fraction.js@5.3.4: {} fsevents@2.3.3: @@ -4287,6 +4760,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -4506,6 +4983,8 @@ snapshots: jiti@1.21.7: {} + jiti@2.7.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -4514,6 +4993,41 @@ snapshots: dependencies: argparse: 2.0.1 + jscpd-darwin-arm64@5.3.2: + optional: true + + jscpd-darwin-x64@5.3.2: + optional: true + + jscpd-linux-arm64-gnu@5.3.2: + optional: true + + jscpd-linux-arm64-musl@5.3.2: + optional: true + + jscpd-linux-x64-gnu@5.3.2: + optional: true + + jscpd-linux-x64-musl@5.3.2: + optional: true + + jscpd-windows-arm64-msvc@5.3.2: + optional: true + + jscpd-windows-x64-msvc@5.3.2: + optional: true + + jscpd@5.3.2: + optionalDependencies: + jscpd-darwin-arm64: 5.3.2 + jscpd-darwin-x64: 5.3.2 + jscpd-linux-arm64-gnu: 5.3.2 + jscpd-linux-arm64-musl: 5.3.2 + jscpd-linux-x64-gnu: 5.3.2 + jscpd-linux-x64-musl: 5.3.2 + jscpd-windows-arm64-msvc: 5.3.2 + jscpd-windows-x64-msvc: 5.3.2 + jsdom@26.1.0: dependencies: cssstyle: 4.6.0 @@ -4562,6 +5076,22 @@ snapshots: dependencies: json-buffer: 3.0.1 + knip@6.38.0: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + formatly: 0.7.1 + get-tsconfig: 4.14.3 + jiti: 2.7.0 + oxc-parser: 0.150.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.7 + smol-toml: 1.9.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.11 + yaml: 2.9.1 + zod: 4.6.5 + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -4595,10 +5125,6 @@ snapshots: lru-cache@11.5.1: {} - lucide-react@0.469.0(react@19.0.0): - dependencies: - react: 19.0.0 - lz-string@1.5.0: {} magic-string@0.30.21: @@ -4738,6 +5264,52 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxc-parser@0.150.0: + dependencies: + '@oxc-project/types': 0.150.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.150.0 + '@oxc-parser/binding-android-arm64': 0.150.0 + '@oxc-parser/binding-darwin-arm64': 0.150.0 + '@oxc-parser/binding-darwin-x64': 0.150.0 + '@oxc-parser/binding-freebsd-x64': 0.150.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.150.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.150.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.150.0 + '@oxc-parser/binding-linux-arm64-musl': 0.150.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.150.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.150.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.150.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.150.0 + '@oxc-parser/binding-linux-x64-gnu': 0.150.0 + '@oxc-parser/binding-linux-x64-musl': 0.150.0 + '@oxc-parser/binding-openharmony-arm64': 0.150.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.150.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.150.0 + '@oxc-parser/binding-win32-x64-msvc': 0.150.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -4748,6 +5320,8 @@ snapshots: package-json-from-dist@1.0.1: {} + package-manager-detector@1.8.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -4777,6 +5351,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.7: {} + pify@2.3.0: {} pirates@4.0.7: {} @@ -4795,12 +5371,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.28 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.28): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.28)(yaml@2.9.1): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.28 + yaml: 2.9.1 postcss-nested@6.2.0(postcss@8.5.28): dependencies: @@ -5072,6 +5649,8 @@ snapshots: siginfo@2.0.0: {} + smol-toml@1.9.0: {} + source-map-js@1.2.1: {} stable-hash@0.0.5: {} @@ -5144,6 +5723,8 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -5171,13 +5752,7 @@ snapshots: symbol-tree@3.2.4: {} - tailwind-merge@2.6.1: {} - - tailwindcss-animate@1.0.7(tailwindcss@3.4.19): - dependencies: - tailwindcss: 3.4.19 - - tailwindcss@3.4.19: + tailwindcss@3.4.19(yaml@2.9.1): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -5196,7 +5771,7 @@ snapshots: postcss: 8.5.28 postcss-import: 15.1.0(postcss@8.5.28) postcss-js: 4.1.0(postcss@8.5.28) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.28) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.28)(yaml@2.9.1) postcss-nested: 6.2.0(postcss@8.5.28) postcss-selector-parser: 6.1.4 resolve: 1.22.12 @@ -5219,8 +5794,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinypool@1.1.1: {} @@ -5309,6 +5884,8 @@ snapshots: typescript@5.9.3: {} + unbash@4.0.11: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -5357,13 +5934,13 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@3.2.4(@types/node@22.19.20)(jiti@1.21.7): + vite-node@3.2.4(@types/node@22.19.20)(jiti@1.21.7)(yaml@2.9.1): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6(@types/node@22.19.20)(jiti@1.21.7) + vite: 7.3.6(@types/node@22.19.20)(jiti@1.21.7)(yaml@2.9.1) transitivePeerDependencies: - '@types/node' - jiti @@ -5378,7 +5955,7 @@ snapshots: - tsx - yaml - vite@7.3.6(@types/node@22.19.20)(jiti@1.21.7): + vite@7.3.6(@types/node@22.19.20)(jiti@1.21.7)(yaml@2.9.1): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.4) @@ -5390,12 +5967,13 @@ snapshots: '@types/node': 22.19.20 fsevents: 2.3.3 jiti: 1.21.7 + yaml: 2.9.1 - vitest@3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0): + vitest@3.2.7(@types/node@22.19.20)(jiti@1.21.7)(jsdom@26.1.0)(yaml@2.9.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.19.20)(jiti@1.21.7)) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.19.20)(jiti@1.21.7)(yaml@2.9.1)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -5413,8 +5991,8 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@22.19.20)(jiti@1.21.7) - vite-node: 3.2.4(@types/node@22.19.20)(jiti@1.21.7) + vite: 7.3.6(@types/node@22.19.20)(jiti@1.21.7)(yaml@2.9.1) + vite-node: 3.2.4(@types/node@22.19.20)(jiti@1.21.7)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.20 @@ -5437,6 +6015,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: @@ -5508,10 +6088,14 @@ snapshots: xmlchars@2.2.0: {} + yaml@2.9.1: {} + yocto-queue@0.1.0: {} zod@3.25.76: {} + zod@4.6.5: {} + zustand@5.0.14(@types/react@19.2.17)(react@19.0.0): optionalDependencies: '@types/react': 19.2.17 diff --git a/wrangler.jsonc b/wrangler.jsonc index b1a6a20..f53b2a5 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,21 +1,8 @@ { - // Cloudflare Pages project configuration for civfix-admin (the operator dashboard, admin.civfix.org). - // - // DEPLOY MODEL: CI build + Direct Upload (NOT Cloudflare's native Git build). - // The @civfix/shared contract is a PRIVATE git submodule with a RELATIVE URL - // (shared/ -> ../civfix-shared). Cloudflare's native Git builds cannot reliably clone - // private/relative submodules, so we DO NOT connect this repo to Pages' git builder. - // Instead, GitHub Actions builds the static export (checking out the submodule with - // credentials, pinned Node 22 + pnpm 9.12.0) and ships the prebuilt out/ to Pages with - // `wrangler pages deploy`. See .github/workflows/deploy.yml and - // documents/phase2/07-civfix-admin-repo.md. - // - // WHAT THIS FILE DOES: makes the deploy declarative. With it present, `wrangler pages deploy` - // (no args) reads the project name and the prebuilt output directory from here, and - // `wrangler pages dev` serves that directory through the real Pages routing engine (so the - // _redirects / _headers behave exactly as in production). It does NOT configure a build - // command or submodule handling - Cloudflare Pages has no repo-level build-command file; - // the build happens in CI. + // Cloudflare Pages project for the operator dashboard. CI builds the static export and ships the + // prebuilt output with `wrangler pages deploy` (Direct Upload), which reads the project name and output + // directory from here; `wrangler pages dev` serves that directory with the real _redirects/_headers. + // There is no Pages Git build: .github/workflows/deploy.yml is the build. "name": "civfix-admin", "pages_build_output_dir": "apps/admin/out", // Pure static export: there are no Pages Functions, so the Workers runtime version is never