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 ea23aa3..cb0b97d 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] # main's merge queue waits for the required checks on its own gh-readonly-queue/* commit; without this # trigger they never report there and every queued PR times out. merge_group: @@ -40,6 +40,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 398acbe..76ff1c6 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: @@ -120,7 +120,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/NOTICE b/NOTICE index d1bcd67..1748758 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -civfix — the operator dashboard +civfix: the operator dashboard Copyright (c) 2026 Reach Out Los Angeles Inc. and contributors This program is free software: you can redistribute it and/or modify it under diff --git a/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 28c27b2..ff79d5a 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -8,25 +8,28 @@ "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 --passWithNoTests", - "clean": "rimraf .next out *.tsbuildinfo" + "test": "vitest run", + "clean": "rimraf .next out *.tsbuildinfo", + "knip": "knip", + "dup": "jscpd -c .jscpd.json && jscpd -c .jscpd.tests.json" }, "dependencies": { - "@civfix/shared": "^0.58.0", + "@civfix/shared": "^0.60.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", + "@testing-library/user-event": "^14.6.7", "@types/leaflet": "^1.9.15", "@types/node": "^22.10.2", "@types/react": "^19.0.2", @@ -34,10 +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 d973710..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,28 +42,12 @@ } } -@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); } + +/* The toast's success icon sits on moss in admin.css; an error must not read as a success. */ +.toast-wrap.error .ico { + background: var(--danger); +} diff --git a/apps/admin/src/app/layout.test.ts b/apps/admin/src/app/layout.test.ts new file mode 100644 index 0000000..8c51a67 --- /dev/null +++ b/apps/admin/src/app/layout.test.ts @@ -0,0 +1,16 @@ +import { existsSync } from "node:fs" + +import { describe, expect, it, vi } from "vitest" + +vi.mock("next/font/google", () => { + const font = () => ({ variable: "font" }) + return { Bricolage_Grotesque: font, Manrope: font, JetBrains_Mono: font } +}) + +describe("root layout metadata", () => { + it("points the browser tab icon at the favicon the export ships", async () => { + const { metadata } = await import("./layout") + expect(metadata.icons).toEqual({ icon: "/favicon.svg" }) + expect(existsSync(new URL("../../public/favicon.svg", import.meta.url))).toBe(true) + }) +}) diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 1736904..e4fa734 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -4,23 +4,15 @@ import { tokens } from "@civfix/shared/tokens" import { Providers } from "@/components/providers" import "./globals.css" -// The ported design system CSS (warm-paper tokens + every shell/component class from the PinIt Admin -// handoff). Imported AFTER globals.css so the design's component classes win over Tailwind's base -// reset wherever they overlap on the shell. admin.css @imports colors-and-type.css itself. +// After globals.css so the design system's component classes win over Tailwind's base reset where +// they overlap on the shell. import "@/styles/admin.css" -// Dashboard app additions the prototype lacked: the Cloudflare Access sign-in gate, boot screen, inline -// spinner, and loading / error / empty states. Built on the same design tokens; imported last so it can -// layer on top of the ported design CSS. +// Last, so the app's own additions layer on top of the design system CSS. import "@/styles/app.css" -/** - * Fonts are loaded via next/font/google and exposed DIRECTLY as the CSS variables the ported design CSS - * consumes (--font-display-next / --font-body-next / --font-mono-next, see colors-and-type.css). Those - * design vars then build the final --font-display / --font-body / --font-mono stacks with literal - * fallbacks. The next/font variable names MUST differ from the design's own --font-display/body/mono, or - * the alias becomes a circular var() reference (which CSS invalidates -> serif fallback). The families - * match tokens.font (Bricolage Grotesque / Manrope / JetBrains Mono), identical to community-web. - */ +// colors-and-type.css builds the --font-display/body/mono stacks from these *-next variables. The names +// must differ from the stacks' own, or the alias becomes a circular var() that CSS invalidates, falling +// back to serif. const display = Bricolage_Grotesque({ subsets: ["latin"], variable: "--font-display-next", @@ -45,11 +37,11 @@ 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 }, } export const viewport: Viewport = { - // Sourced from the shared token (neutral.paper) so browser chrome matches the app background. themeColor: tokens.color.neutral.paper, width: "device-width", initialScale: 1, diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx index a554407..5d1f281 100644 --- a/apps/admin/src/app/page.tsx +++ b/apps/admin/src/app/page.tsx @@ -1,11 +1,7 @@ import { AppShell } from "@/components/shell/app-shell" -/** - * The dashboard is a single client-rooted SPA shell. The static export emits just the HTML shell + JS; - * the login gate (providers.tsx) and AppShell render entirely on the client, and all data is fetched - * at runtime. There is exactly one route - section navigation is client-side page state (no Next - * routes), so the export produces a single out/index.html plus the SPA fallback in public/_redirects. - */ +// The only route: sections are client-side page state, so the static export emits a single +// out/index.html, served for every path by the SPA fallback in public/_redirects. export default function HomePage() { return } diff --git a/apps/admin/src/components/auth/auth-hydrator.tsx b/apps/admin/src/components/auth/auth-hydrator.tsx index eb6f373..2f0e099 100644 --- a/apps/admin/src/components/auth/auth-hydrator.tsx +++ b/apps/admin/src/components/auth/auth-hydrator.tsx @@ -4,17 +4,6 @@ import * as React from "react" import { useOperatorBootstrap } from "@/hooks/use-admin-auth" -/** - * Bootstraps the operator session via Cloudflare Access exactly once on mount (doc 16, same-origin). - * - * The SPA and the `/admin/*` API are served behind the same Access app on the same origin, so by the time - * this runs the browser already holds the Access cookie. The bootstrap (useOperatorBootstrap) first reuses - * a still-valid operator session (GET /admin/auth/session), and otherwise exchanges the edge-injected - * Access JWT (POST /admin/auth/access/exchange) for one. On success the auth store flips to authenticated - * and the gate (providers.tsx) renders the dashboard; a 403 (authenticated but not allowlisted) lands on - * the not-authorized state; any other failure lands on the retryable login screen. No cross-origin cookie - * bootstrap / redirect is needed in this same-origin deployment. - */ export function AuthHydrator() { const bootstrap = useOperatorBootstrap() diff --git a/apps/admin/src/components/icons.tsx b/apps/admin/src/components/icons.tsx index a4e59dc..6de6a2d 100644 --- a/apps/admin/src/components/icons.tsx +++ b/apps/admin/src/components/icons.tsx @@ -1,23 +1,14 @@ -/** - * Icons (Lucide-style inline SVG), ported from the design prototype (icons.jsx) to a typed TSX module. - * Stroke 1.75, round caps/joins, currentColor. Every name the design's `Icons.*` set used is present, - * so ported components reference `Icons.Pin`, `Icons.ChevronRight`, etc. exactly as in the prototype. - */ - -export interface IconProps { - /** Pixel size for width + height (viewBox stays 24). Defaults to 16. */ +interface IconProps { size?: number fill?: string stroke?: string - /** Stroke width. Defaults to 1.75. */ + /** Stroke width. */ sw?: number className?: string } interface BaseIconProps extends IconProps { - /** A single path string, OR... */ d?: string - /** ...several path strings. */ paths?: string[] } @@ -48,12 +39,10 @@ function Icon({ ) } -/** A single icon component: takes IconProps (size/fill/stroke/sw/className). */ export type IconComponent = (props: IconProps) => React.ReactElement -// NOTE: declared as a plain object literal (not `Record`) so that each known -// key (Icons.Pin, Icons.ChevronRight, ...) is non-optional under `noUncheckedIndexedAccess`. The -// `satisfies` clause still enforces that every value is a valid IconComponent. +// A plain literal with `satisfies`, not `Record`, so every known key stays +// non-optional under `noUncheckedIndexedAccess`. export const Icons = { Pin: (p) => ( ), - Map: (p) => , Layers: (p) => ( ), @@ -96,15 +84,6 @@ export const Icons = { BarChart: (p) => ( ), - Settings: (p) => ( - - ), Inbox: (p) => ( , Plus: (p) => , - Bell: (p) => ( - - ), Check: (p) => , X: (p) => , ChevronRight: (p) => , ChevronLeft: (p) => , ChevronDown: (p) => , - ChevronUp: (p) => , ArrowUp: (p) => , ArrowDown: (p) => , ArrowRight: (p) => , @@ -160,7 +135,6 @@ export const Icons = { ), Lock: (p) => , - Bookmark: (p) => , FileText: (p) => ( ), - Phone: (p) => ( - - ), - Sidebar: (p) => , Trash: (p) => ( ( ), - Filter: (p) => , MoreH: (p) => , CornerArr: (p) => , Globe: (p) => ( diff --git a/apps/admin/src/components/map/boundary-map.tsx b/apps/admin/src/components/map/boundary-map.tsx index e553097..d6ffc0b 100644 --- a/apps/admin/src/components/map/boundary-map.tsx +++ b/apps/admin/src/components/map/boundary-map.tsx @@ -3,27 +3,19 @@ import * as React from "react" import L from "leaflet" -import { withCartoKey } from "@/lib/carto" +import { addCartoBasemap, useLeafletMap } from "@/components/map/leaflet-base" -/** The GeoJSON object type L.geoJSON accepts, derived from Leaflet's own signature (avoids a direct @types/geojson import). */ -type LeafletGeoJson = Parameters[0] +// Derived from Leaflet's own signature, so no direct @types/geojson dependency is needed. +type LeafletGeoJson = Parameters[0] -/** - * A small Leaflet map that draws ONE jurisdiction's boundary polygon and fits to it — the directory's - * "is this in the right place?" verification view. CARTO Voyager raster basemap (same tiles as the - * marker map.tsx); the polygon is the server-simplified GeoJSON from GET /admin/jurisdictions/:geoid/ - * geometry. Like leaflet-map.tsx this imports Leaflet at the top level, so it MUST be loaded client-only - * via next/dynamic (ssr:false). Leaflet's CSS is imported globally in globals.css. - */ +// Leaflet touches window at import, so this module must be loaded through next/dynamic with ssr:false. -const TILE = { - url: withCartoKey("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"), - 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 } +const SHAPE_WEIGHT = 2 +const SHAPE_FILL_OPACITY = 0.18 -/** Boundary fill/stroke by jurisdiction layer, so a city reads differently from a county/federal land. */ const LAYER_COLOR: Record = { place: "#5B8C6E", county: "#3F7CAC", @@ -32,64 +24,44 @@ const LAYER_COLOR: Record = { tribal: "#B0593F", } -export interface BoundaryMapProps { - /** GeoJSON Polygon/MultiPolygon geometry. */ +interface BoundaryMapProps { geometry: { type: string; coordinates: unknown } - /** [west, south, east, north] for fit-bounds. */ + /** [west, south, east, north] */ bbox: [number, number, number, number] - /** Jurisdiction layer, drives the boundary color. */ layer?: string } export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProps) { const elRef = React.useRef(null) - const mapRef = React.useRef(null) const shapeRef = React.useRef(null) - // Create the map once. - React.useEffect(() => { - if (!elRef.current || mapRef.current) return - const map = L.map(elRef.current, { - zoomControl: true, - attributionControl: true, - scrollWheelZoom: false, // require an explicit zoom (it sits inside a scrollable detail panel) - }) - mapRef.current = map - L.tileLayer(TILE.url, { - attribution: TILE.attribution, - subdomains: TILE.subdomains, - maxZoom: 19, - detectRetina: true, - }).addTo(map) - - const ro = new ResizeObserver(() => map.invalidateSize()) - ro.observe(elRef.current) - const t0 = setTimeout(() => map.invalidateSize(), 60) - - return () => { - ro.disconnect() - clearTimeout(t0) - 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 - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, + ) - // Draw / redraw the boundary and fit to its bbox whenever the geometry changes. React.useEffect(() => { const map = mapRef.current - if (!map) return - 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: 2, fillColor: color, fillOpacity: 0.18 }, - }).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 @@ -98,9 +70,9 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp [south, west], [north, east], ], - { padding: [12, 12], maxZoom: 13 }, + 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.test.tsx b/apps/admin/src/components/map/leaflet-map.test.tsx new file mode 100644 index 0000000..b6e9de2 --- /dev/null +++ b/apps/admin/src/components/map/leaflet-map.test.tsx @@ -0,0 +1,286 @@ +import { fireEvent, render } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import L from "leaflet" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { BoundaryMap } from "@/components/map/boundary-map" +import { LeafletMap, tooltipText, type MapPin } from "@/components/map/leaflet-map" + +// jsdom has no layout engine: Leaflet sizes the map from clientWidth/clientHeight (0 in jsdom, which +// makes fitBounds produce NaN coordinates) and the wrappers observe their container for resizes. +beforeEach(() => { + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800) + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600) + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +const PINS: MapPin[] = [ + { id: "r1", lat: 34.05, lng: -118.25, category: "pothole", tip: "Deep pothole", place: "Los Angeles" }, + { id: "r2", lat: 34.06, lng: -118.24, category: "graffiti", draft: true, label: "Tagged wall" }, + { id: "e1", lat: 34.07, lng: -118.23, kind: "event", tip: "Park cleanup" }, +] + +function markerIcons(container: HTMLElement): HTMLElement[] { + return [...container.querySelectorAll(".leaflet-marker-icon")] +} + +function hoverAndReadTooltip(container: HTMLElement, icon: HTMLElement): HTMLElement | null { + fireEvent.mouseOver(icon) + return container.querySelector(".leaflet-tooltip") +} + +describe("LeafletMap", () => { + it("renders one marker per pin with the state-specific pin markup", () => { + const { container } = render() + + const icons = markerIcons(container) + expect(icons).toHaveLength(3) + expect(icons.map((el) => el.querySelector(".pi-pin2")?.className)).toEqual([ + "pi-pin2 routed", + "pi-pin2 needs", + "pi-pin2 event", + ]) + for (const el of icons) expect(el).toHaveClass("pi-pin2-wrap") + }) + + it("renders the active pin with the active treatment", () => { + const { container } = render() + + const active = container.querySelectorAll(".pi-pin2.is-active") + expect(active).toHaveLength(1) + expect(active[0]).toHaveClass("needs") + }) + + it("reconciles markers when the pin list changes", () => { + const { container, rerender } = render() + expect(markerIcons(container)).toHaveLength(3) + + rerender() + expect(markerIcons(container)).toHaveLength(1) + }) + + it("shows the title and place in the tooltip, joined by a middle dot", () => { + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) + expect(tip).not.toBeNull() + expect(tip).toHaveClass("pi-map-tip") + expect(tip).toHaveTextContent("Deep pothole · Los Angeles") + }) + + it("falls back to the label when a pin has no tip and omits the place suffix", () => { + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[1]!) + expect(tip?.textContent).toBe("Tagged wall") + }) + + it("binds no tooltip for a pin with neither tip nor label", () => { + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) + expect(tip).toBeNull() + }) + + it("renders a pin title and place in the tooltip as literal text, never as markup", () => { + const pin: MapPin = { id: "h", lat: 34, lng: -118, tip: "x", place: "y" } + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) + expect(tip?.querySelector("b")).toBeNull() + expect(tip?.querySelector("i")).toBeNull() + expect(tip?.textContent).toBe("x · y") + }) + + it("never runs markup from a citizen-authored title in the tooltip", () => { + const payload = '' + const pin: MapPin = { id: "x", lat: 34, lng: -118, tip: payload, place: "" } + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) + expect(tip?.querySelector("img, svg")).toBeNull() + expect(tip?.textContent).toBe(`${payload} · `) + }) + + it("keeps a label with entity-like text literal in the tooltip", () => { + const pin: MapPin = { id: "a", lat: 34, lng: -118, label: "Fish & Chips" } + const { container } = render() + + const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) + expect(tip?.textContent).toBe("Fish & Chips") + }) + + it.each(["constructor", "toString", "__proto__"])( + "draws the generic glyph for the inherited object key %j as a category", + (category) => { + const { container } = render( + , + ) + + const glyphs = markerIcons(container).map((el) => el.querySelector("g path")?.getAttribute("d")) + expect(glyphs[0]).toBe(glyphs[1]) + }, + ) + + it("calls onPinTap with the tapped pin", () => { + const onPinTap = vi.fn() + const { container } = render() + + fireEvent.click(markerIcons(container)[2]!) + expect(onPinTap).toHaveBeenCalledTimes(1) + expect(onPinTap).toHaveBeenCalledWith(PINS[2]) + }) + + it("updates a pin's tooltip when its title changes under the same id", () => { + const { container, rerender } = render() + hoverAndReadTooltip(container, markerIcons(container)[0]!) + + rerender() + const tip = hoverAndReadTooltip(container, markerIcons(container)[0]!) + expect(tip?.textContent).toBe("Filled pothole · Los Angeles") + }) + + it("binds a tooltip when a pin gains a title and drops it when the title goes", () => { + const bare: MapPin = { id: "x", lat: 34, lng: -118 } + const { container, rerender } = render() + + rerender() + expect(hoverAndReadTooltip(container, markerIcons(container)[0]!)?.textContent).toBe("Now titled") + + fireEvent.mouseOut(markerIcons(container)[0]!) + rerender() + expect(hoverAndReadTooltip(container, markerIcons(container)[0]!)).toBeNull() + }) + + it("hands onPinTap the pin's latest data after a refetch", () => { + const onPinTap = vi.fn() + const { container, rerender } = render() + const refetched: MapPin = { ...PINS[2]!, tip: "Park cleanup (moved)" } + + rerender() + fireEvent.click(markerIcons(container)[2]!) + expect(onPinTap).toHaveBeenCalledWith(refetched) + }) + + it("names each interactive marker by its tooltip text", () => { + const { container } = render() + + const icons = markerIcons(container) + expect(icons.map((el) => el.getAttribute("role"))).toEqual(["button", "button", "button"]) + expect(icons.map((el) => el.getAttribute("aria-label"))).toEqual([ + "Deep pothole · Los Angeles", + "Tagged wall", + "Park cleanup", + ]) + }) + + it("renames a marker when its title changes", () => { + const { container, rerender } = render() + + rerender() + expect(markerIcons(container)[0]).toHaveAttribute("aria-label", "Filled pothole · Los Angeles") + }) + + it("keeps markers of a non-interactive map out of the tab order", () => { + const { container } = render() + + for (const el of markerIcons(container)) { + expect(el).not.toHaveAttribute("tabindex") + expect(el).not.toHaveAttribute("role") + } + }) + + it.each(["{Enter}", " "])("activates a focused marker with %j like a tap", async (key) => { + const onPinTap = vi.fn() + const { container } = render() + + const icon = markerIcons(container)[1]! + icon.focus() + await userEvent.setup().keyboard(key) + expect(onPinTap).toHaveBeenCalledTimes(1) + expect(onPinTap).toHaveBeenCalledWith(PINS[1]) + }) + + it("moves the view when the center or zoom props change", () => { + const setView = vi.spyOn(L.Map.prototype, "setView") + const { rerender } = render() + + rerender() + expect(setView).toHaveBeenLastCalledWith([40.7, -74], 12) + }) + + it("removes the Leaflet map on unmount", () => { + const remove = vi.spyOn(L.Map.prototype, "remove") + const { unmount } = render() + expect(remove).not.toHaveBeenCalled() + + unmount() + expect(remove).toHaveBeenCalledTimes(1) + }) +}) + +describe("BoundaryMap", () => { + const square = { + type: "Polygon", + coordinates: [ + [ + [-118.3, 34.0], + [-118.2, 34.0], + [-118.2, 34.1], + [-118.3, 34.1], + [-118.3, 34.0], + ], + ], + } + const bbox: [number, number, number, number] = [-118.3, 34.0, -118.2, 34.1] + + it("draws the boundary polygon", () => { + const { container } = render() + + const path = container.querySelector("path.leaflet-interactive") + expect(path).not.toBeNull() + expect(path).toHaveAttribute("stroke", "#3F7CAC") + }) + + it("removes the Leaflet map on unmount", () => { + const remove = vi.spyOn(L.Map.prototype, "remove") + const { unmount } = render() + expect(remove).not.toHaveBeenCalled() + + unmount() + expect(remove).toHaveBeenCalledTimes(1) + }) +}) + +describe("tooltipText", () => { + it("joins the title and place with a middle dot", () => { + expect(tooltipText({ id: "1", lat: 0, lng: 0, tip: "Pothole", place: "Oakland" })).toBe( + "Pothole · Oakland", + ) + }) + + it("falls back to the label and omits a missing place", () => { + expect(tooltipText({ id: "1", lat: 0, lng: 0, label: "Wall" })).toBe("Wall") + }) + + it("returns null when there is no title to show", () => { + expect(tooltipText({ id: "1", lat: 0, lng: 0, place: "Oakland" })).toBeNull() + }) +}) diff --git a/apps/admin/src/components/map/leaflet-map.tsx b/apps/admin/src/components/map/leaflet-map.tsx index e1eba96..07d1aa9 100644 --- a/apps/admin/src/components/map/leaflet-map.tsx +++ b/apps/admin/src/components/map/leaflet-map.tsx @@ -3,27 +3,20 @@ import * as React from "react" import L from "leaflet" -import { withCartoKey } from "@/lib/carto" +import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation" +import { addCartoBasemap, setCartoTint, useLeafletMap, type CartoTint } from "@/components/map/leaflet-base" import { CATEGORY_GLYPHS } from "@/lib/category" -/** - * The civfix universal map (ported from the design's map.jsx PinItMap). Real OpenStreetMap data via - * Leaflet + CARTO raster tiles - a deliberate design-fidelity choice for this internal tool (it matches - * the prototype exactly and does NOT use MapLibre/pmtiles like community-web). - * - * This module imports Leaflet at the top level, so it MUST only ever be loaded on the client. It is - * imported via next/dynamic with `ssr:false` from live-map.tsx (and any future minimap). Leaflet's CSS - * is imported globally in src/app/globals.css. - */ +// Leaflet rather than community-web's MapLibre seam: a deliberate choice for this internal tool. +// Leaflet touches window at import, so this module must be loaded through next/dynamic with ssr:false. export interface MapPin { - /** Stable marker id. */ id: string lat: number lng: number - /** Report category for the glyph; null/omitted falls back to a generic pin. */ + /** Null or omitted falls back to the generic glyph. */ category?: string | null - /** When true, renders the red "needs attention" pin. */ + /** Renders the red "needs attention" pin. */ draft?: boolean /** "event" renders the yellow cleanup pin; "event-volunteer" the moss other-volunteer pin. */ kind?: string | null @@ -33,27 +26,20 @@ 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 = CartoTint -export type MapTint = keyof typeof MAP_TILES +const MAP_HOME = { center: [39.5, -98.35] as [number, number], zoom: 4 } +const BASEMAP_MAX_ZOOM = 20 -export const MAP_HOME = { center: [39.5, -98.35] as [number, number], zoom: 4 } +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. +const PIN_GLYPH_OFFSET = "translate(20 16)" +const PIN_WIDTH = 31 +const PIN_WIDTH_ACTIVE = 40 -// Brand pin as a Leaflet divIcon. Routed jurisdictions render gray with a glyph; jurisdictions that -// still need a routing contact render red; events render yellow. (Ported from map.jsx.) +// Report pins are gray once routed and red while they still need a routing contact; events take the +// fill of their kind. const TEARDROP = "M32 4 C46 4 58 16 58 30 C58 46 40 60 34 68 C33 69 31 69 30 68 C24 60 6 46 6 30 C6 16 18 4 32 4 Z" const GLYPHS: Record = { @@ -61,47 +47,170 @@ const GLYPHS: Record = { cleanup: "M3 6 L21 6 M19 6 V20 a2 2 0 0 1 -2 2 H7 a2 2 0 0 1 -2 -2 V6 M9 6 V4 a1 1 0 0 1 1 -1 h4 a1 1 0 0 1 1 1 V6 M9 11 V17 M12 11 V17 M15 11 V17", event: "M4 7 a1 1 0 0 1 1 -1 h14 a1 1 0 0 1 1 1 v12 a1 1 0 0 1 -1 1 H5 a1 1 0 0 1 -1 -1 Z M16 4 v4 M8 4 v4 M4 11 h16", - // "Other Volunteer" events: a cupped-hands-with-heart glyph, distinct from the cleanup calendar. "event-volunteer": "M12 9 a2 2 0 0 1 3 -1.3 a2 2 0 0 1 0.5 3 L12 14 L8.5 10.7 a2 2 0 0 1 0.5 -3 A2 2 0 0 1 12 9 Z M4 13 v5 a1 1 0 0 0 1 1 h2 v-6 Z M20 13 v5 a1 1 0 0 1 -1 1 h-2 v-6 Z", } const PIN_FILL: Record = { - routed: "#8A8378", + routed: "var(--ink-3)", needs: "#E5564B", - event: "#E5AE1C", + event: "var(--sun-600)", "event-volunteer": "#5B8C6E", } -// Marker kinds that render with the "event" family of treatments (no draft/needs override). +// Event kinds own their fill and glyph; `draft` never overrides them. const EVENT_KINDS = new Set(["event", "event-volunteer"]) function pinIcon( category: string | null | undefined, { active = false, draft = false, kind = null }: { active?: boolean; draft?: boolean; kind?: string | null }, ): L.DivIcon { - // Event markers (cleanup / other-volunteer) own their own fill + glyph by kind; everything else is a - // report pin (red when it needs attention, gray when handled). const isEvent = kind != null && EVENT_KINDS.has(kind) const state = isEvent ? kind : draft ? "needs" : "routed" - const glyph = GLYPHS[isEvent ? kind : category || "other"] ?? CATEGORY_GLYPHS.other + const glyphKey = isEvent ? kind : category || "other" + // The icon html goes through innerHTML and the category arrives from the API unvalidated, so only a + // constant own entry of GLYPHS may reach it (an inherited key like "constructor" would not). + const glyph = Object.hasOwn(GLYPHS, glyphKey) ? GLYPHS[glyphKey]! : CATEGORY_GLYPHS.other const fill = PIN_FILL[state] ?? PIN_FILL.routed - const w = active ? 40 : 31 - const h = w * (76 / 64) + const width = active ? PIN_WIDTH_ACTIVE : PIN_WIDTH + const height = width * (PIN_VIEWBOX_HEIGHT / PIN_VIEWBOX_WIDTH) const html = - `
` + - `` + - `` + - `` + + `
` + + `` + + `` + + `` + `` + `
` return L.divIcon({ html, className: "pi-pin2-wrap", - iconSize: [w, h], - iconAnchor: [w / 2, h], - popupAnchor: [0, -h], + iconSize: [width, height], + iconAnchor: [width / 2, height], + popupAnchor: [0, -height], }) } +export function tooltipText(pin: MapPin): string | null { + const title = pin.tip || pin.label + if (!title) return null + return pin.place ? `${title} · ${pin.place}` : title +} + +// Leaflet writes string tooltip content with innerHTML and pin titles/places are citizen-authored, so +// the content must be a node whose text is set with textContent. +function tooltipNode(text: string): HTMLElement { + const el = document.createElement("span") + el.textContent = text + return el +} + +// Lifts the tooltip clear of the pin head, since the marker is anchored at the pin's tip. +const PIN_TOOLTIP_OFFSET: L.PointExpression = [0, -30] +const TOOLTIP_OPTIONS: L.TooltipOptions = { + direction: "top", + offset: PIN_TOOLTIP_OFFSET, + className: "pi-map-tip", +} + +// A keyboard marker is a role=button tab stop, and its divIcon has no text of its own to name it. +function labelMarker(marker: L.Marker, text: string | null): void { + const el = marker.getElement() + if (!el) return + if (text) el.setAttribute("aria-label", text) + else el.removeAttribute("aria-label") +} + +function syncTooltip(marker: L.Marker, text: string | null): void { + if (!text) marker.unbindTooltip() + else if (marker.getTooltip()) marker.setTooltipContent(tooltipNode(text)) + else marker.bindTooltip(tooltipNode(text), TOOLTIP_OPTIONS) +} + +// What each marker last rendered, so a sync skips the DivIcon rebuild and DOM teardown, the tooltip +// rebind and setLatLng when nothing visible changed. Without it one activeId change re-icons every +// marker instead of just the two whose active state flipped. +interface RenderedMarker { + key: string + text: string | null + lat: number + lng: number +} + +interface MarkerSync { + activeId: string | null + interactive: boolean + markers: Record + rendered: Record + latestPins: Record + onTap: (pin: MapPin) => void +} + +function createMarker( + map: L.Map, + pin: MapPin, + icon: L.DivIcon, + text: string | null, + sync: MarkerSync, +): L.Marker { + const marker = L.marker([pin.lat, pin.lng], { icon, riseOnHover: true, keyboard: sync.interactive }) + marker.addTo(map) + syncTooltip(marker, text) + if (sync.interactive) labelMarker(marker, text) + const tap = () => { + const latest = sync.latestPins[pin.id] + if (latest) sync.onTap(latest) + } + marker.on("click", tap) + marker.on("keydown", (e: L.LeafletKeyboardEvent) => { + if (!isKeyboardActivationKey(e.originalEvent.key)) return + e.originalEvent.preventDefault() + tap() + }) + return marker +} + +function updateMarker( + marker: L.Marker, + prev: RenderedMarker | undefined, + next: RenderedMarker, + icon: () => L.DivIcon, + interactive: boolean, +): void { + if (!prev || prev.key !== next.key) marker.setIcon(icon()) + if (!prev || prev.text !== next.text) { + syncTooltip(marker, next.text) + if (interactive) labelMarker(marker, next.text) + } + if (!prev || prev.lat !== next.lat || prev.lng !== next.lng) marker.setLatLng([next.lat, next.lng]) +} + +function syncMarkers(map: L.Map, pins: MapPin[], sync: MarkerSync): void { + const { markers, rendered, latestPins } = sync + const nextIds = new Set(pins.map((p) => p.id)) + for (const id of Object.keys(markers)) { + if (nextIds.has(id)) continue + markers[id]?.remove() + delete markers[id] + delete rendered[id] + delete latestPins[id] + } + + for (const p of pins) { + latestPins[p.id] = p + const active = String(p.id) === String(sync.activeId) + const next: RenderedMarker = { + // Everything pinIcon() depends on: an equal key means an identical DivIcon. + key: `${p.category}|${p.draft}|${p.kind}|${active}`, + text: tooltipText(p), + lat: p.lat, + lng: p.lng, + } + const icon = () => pinIcon(p.category, { active, draft: p.draft, kind: p.kind }) + const existing = markers[p.id] + if (existing) updateMarker(existing, rendered[p.id], next, icon, sync.interactive) + else markers[p.id] = createMarker(map, p, icon(), next.text, sync) + rendered[p.id] = next + } +} + export interface LeafletMapProps { pins?: MapPin[] center?: [number, number] @@ -112,10 +221,6 @@ export interface LeafletMapProps { onPinTap?: (pin: MapPin) => void } -/** - * Imperative Leaflet wrapper. Creates the map once, swaps the tile layer on tint change, and - * reconciles markers when `pins` / `activeId` change. Mirrors map.jsx PinItMap behavior. - */ export function LeafletMap({ pins = [], center = MAP_HOME.center, @@ -126,130 +231,66 @@ export function LeafletMap({ onPinTap, }: LeafletMapProps) { const elRef = React.useRef(null) - const mapRef = React.useRef(null) const tileRef = React.useRef(null) const markersRef = React.useRef>({}) - // Per-id memo of the last-rendered visual descriptor (category|draft|kind|active) and position, so - // reconcile can skip the expensive DivIcon rebuild + DOM teardown (setIcon) and the setLatLng call - // when nothing visible actually changed for that marker. Without this, a single activeId change - // re-icons and DOM-replaces ALL N markers; with it, only the de-activated + newly-active markers do. - const renderRef = React.useRef>({}) + const renderRef = React.useRef>({}) + // The latest pin per id: marker handlers are bound once, and a refetch replaces the pin objects. + const pinsRef = React.useRef>({}) // Keep the latest onPinTap without re-running the create effect. const onPinTapRef = React.useRef(onPinTap) onPinTapRef.current = onPinTap - // Create the map once. - React.useEffect(() => { - if (!elRef.current || mapRef.current) return - const map = L.map(elRef.current, { - center, - zoom, - zoomControl: false, - attributionControl: true, - dragging: interactive, - scrollWheelZoom: interactive, - doubleClickZoom: interactive, - touchZoom: interactive, - boxZoom: false, - keyboard: false, - // Note: Leaflet's legacy `tap` option was dropped from @types/leaflet (and is a no-op in modern - // Leaflet), so it is intentionally omitted here; touch tap is handled natively. - }) - mapRef.current = map - - const t = MAP_TILES[tint] ?? MAP_TILES.voyager - tileRef.current = L.tileLayer(t.url, { - attribution: t.attribution, - subdomains: t.subdomains, - maxZoom: 20, - detectRetina: true, - }).addTo(map) - - if (interactive) { - L.control.zoom({ position: "topright" }).addTo(map) - } - - const ro = new ResizeObserver(() => map.invalidateSize()) - ro.observe(elRef.current) - const t0 = setTimeout(() => map.invalidateSize(), 60) - - return () => { - ro.disconnect() - clearTimeout(t0) - 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 = {} - } - // Intentionally run once on mount. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + pinsRef.current = {} + }, + ) - // Swap tiles when tint changes. + const [centerLat, centerLng] = center React.useEffect(() => { - const map = mapRef.current - if (!map) return - if (tileRef.current) tileRef.current.remove() - const t = MAP_TILES[tint] ?? MAP_TILES.voyager - tileRef.current = L.tileLayer(t.url, { - attribution: t.attribution, - subdomains: t.subdomains, - maxZoom: 20, - detectRetina: true, - }).addTo(map) + mapRef.current?.setView([centerLat, centerLng], zoom) + }, [mapRef, centerLat, centerLng, zoom]) + + React.useEffect(() => { + if (tileRef.current) setCartoTint(tileRef.current, tint) }, [tint]) - // Reconcile markers. React.useEffect(() => { const map = mapRef.current if (!map) return - const next: Record = {} - pins.forEach((p) => { - next[p.id] = p - }) - - Object.keys(markersRef.current).forEach((id) => { - if (!next[id]) { - markersRef.current[id]?.remove() - delete markersRef.current[id] - delete renderRef.current[id] - } - }) - - pins.forEach((p) => { - const existing = markersRef.current[p.id] - const active = String(p.id) === String(activeId) - // One cheap string capturing everything pinIcon() depends on. Equal key => identical DivIcon, so - // we can skip rebuilding the HTML/SVG and the setIcon DOM teardown entirely. - const key = `${p.category}|${p.draft}|${p.kind}|${active}` - if (existing) { - const prev = renderRef.current[p.id] - // Re-icon only when the visual descriptor changed (e.g. this pin just gained/lost active). - if (!prev || prev.key !== key) { - existing.setIcon(pinIcon(p.category, { active, draft: p.draft, kind: p.kind })) - } - // Re-position only when the coordinates actually moved. - if (!prev || prev.lat !== p.lat || prev.lng !== p.lng) { - existing.setLatLng([p.lat, p.lng]) - } - renderRef.current[p.id] = { key, lat: p.lat, lng: p.lng } - } else { - const icon = pinIcon(p.category, { active, draft: p.draft, kind: p.kind }) - const m = L.marker([p.lat, p.lng], { icon, riseOnHover: true }).addTo(map) - const tip = p.tip || p.label - if (tip) { - m.bindTooltip(tip + (p.place ? ` · ${p.place}` : ""), { - direction: "top", - offset: [0, -30], - className: "pi-map-tip", - }) - } - m.on("click", () => onPinTapRef.current?.(p)) - markersRef.current[p.id] = m - renderRef.current[p.id] = { key, lat: p.lat, lng: p.lng } - } + syncMarkers(map, pins, { + activeId, + interactive, + markers: markersRef.current, + rendered: renderRef.current, + latestPins: pinsRef.current, + onTap: (pin) => onPinTapRef.current?.(pin), }) - }, [pins, activeId]) + }, [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 new file mode 100644 index 0000000..2dcfc13 --- /dev/null +++ b/apps/admin/src/components/map/live-map.test.tsx @@ -0,0 +1,130 @@ +import { act, screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { HomeMapPin } from "@civfix/shared" +import { describe, expect, it, vi } from "vitest" + +import type * as ApiModule from "@/lib/api" +import type { LeafletMapProps } from "@/components/map/leaflet-map" +import { LiveMap } from "@/components/map/live-map" +import { queryKeys } from "@/lib/query" +import { apiMock } from "@/test/api-mock" +import { renderWithQuery } from "@/test/render" + +vi.mock("@/lib/api", async (importOriginal) => { + const { apiMock } = await import("@/test/api-mock") + return { ...(await importOriginal()), api: apiMock } +}) + +vi.mock("@/components/map/leaflet-map", () => ({ + LeafletMap: ({ pins = [], onPinTap }: LeafletMapProps) => ( +
+ {pins.map((p) => ( + + ))} +
+ ), +})) + +function reportPin(over: Partial = {}): HomeMapPin { + return { + refType: "report", + id: "r1", + lat: 34, + lng: -118, + category: "graffiti", + status: "published", + flagged: false, + title: "Tagged wall", + place: "Echo Park", + ...over, + } +} + +function eventPin(over: Partial = {}): HomeMapPin { + return { + refType: "event", + id: "e1", + lat: 34.1, + lng: -118.1, + category: null, + eventKind: "cleanup", + status: "upcoming", + flagged: false, + title: "Park cleanup", + place: "Elysian Park", + ...over, + } +} + +async function renderMap(pins: HomeMapPin[]) { + apiMock.adminHomeMap.mockResolvedValue({ pins }) + const user = userEvent.setup() + const view = renderWithQuery() + return { user, ...view } +} + +async function tap(user: ReturnType, id: string): Promise { + await user.click(await screen.findByRole("button", { name: `pin ${id}` })) +} + +describe("LiveMap active card", () => { + it("titles an untitled report by its category", async () => { + const { user } = await renderMap([reportPin({ title: "" })]) + await tap(user, "report-r1") + + expect(screen.getByText("Graffiti report")).toBeInTheDocument() + }) + + it("titles an untitled report without a category as a report", async () => { + const { user } = await renderMap([reportPin({ title: "", category: null })]) + await tap(user, "report-r1") + + expect(screen.getByText("Report")).toBeInTheDocument() + }) + + it.each([ + ["cleanup", "Cleanup event"], + ["other_volunteer", "Other Volunteer event"], + ] as const)("labels a %s event pin by its kind", async (eventKind, label) => { + const { user } = await renderMap([eventPin({ eventKind })]) + await tap(user, "event-e1") + + expect(screen.getByText(label)).toBeInTheDocument() + }) + + it("follows the tapped pin's data when the feed refetches", async () => { + const { user, client } = await renderMap([reportPin()]) + await tap(user, "report-r1") + expect(screen.getByText("Tagged wall")).toBeInTheDocument() + + act(() => { + client.setQueryData(queryKeys.home.map, { pins: [reportPin({ title: "Wall repainted" })] }) + }) + expect(await screen.findByText("Wall repainted")).toBeInTheDocument() + expect(screen.queryByText("Tagged wall")).toBeNull() + }) + + it("drops the card when the tapped pin leaves the feed", async () => { + const { user, client } = await renderMap([reportPin(), eventPin()]) + await tap(user, "report-r1") + + act(() => { + client.setQueryData(queryKeys.home.map, { pins: [eventPin()] }) + }) + await waitFor(() => expect(screen.queryByText("Tagged wall")).toBeNull()) + expect(screen.queryByRole("button", { name: /Open report/ })).toBeNull() + }) + + it("announces the card politely and closes it from its Close button", async () => { + const { user } = await renderMap([reportPin()]) + await tap(user, "report-r1") + + const card = screen.getByText("Tagged wall").closest(".map-active-card") as HTMLElement + expect(card.closest('[aria-live="polite"]')).not.toBeNull() + + await user.click(within(card).getByRole("button", { name: "Close" })) + expect(screen.queryByText("Tagged wall")).toBeNull() + }) +}) diff --git a/apps/admin/src/components/map/live-map.tsx b/apps/admin/src/components/map/live-map.tsx index c74577d..80fbf3b 100644 --- a/apps/admin/src/components/map/live-map.tsx +++ b/apps/admin/src/components/map/live-map.tsx @@ -5,72 +5,148 @@ 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 { EVENT_KIND_PIN_KIND } from "@/lib/event-kind" -import { - BUCKET_VIEW, - reportBucketOf, - reportNeedsAttention, - type ReportBucket, -} from "@/lib/report-status" +import { categoryLabel } from "@/lib/category" +import { EVENT_KIND_PIN_KIND, eventKindLabel } from "@/lib/event-kind" +import { reportNeedsAttention, reportStatusView } from "@/lib/report-status" import type { MapPin, MapTint } from "@/components/map/leaflet-map" - const LeafletMap = dynamic(() => import("@/components/map/leaflet-map").then((m) => m.LeafletMap), { ssr: false, loading: () =>
, }) -function toMapPin(p: HomeMapPin): MapPin & { +// The feed sends an empty title for an untitled report. +function pinTitle(p: HomeMapPin): string { + if (p.title) return p.title + return p.category ? `${categoryLabel(p.category)} report` : "Report" +} + +interface LivePin extends MapPin { + tip: string refType: HomeMapPin["refType"] refId: string + eventKind: NonNullable | null status: HomeMapPin["status"] flagged: boolean - title: string -} { +} + +function toMapPin(p: HomeMapPin): LivePin { const isEvent = p.refType === "event" const needs = !isEvent && reportNeedsAttention(p.status, p.flagged) + const eventKind = isEvent ? (p.eventKind ?? "cleanup") : null return { id: `${p.refType}-${p.id}`, refType: p.refType, refId: p.id, + eventKind, lat: p.lat, lng: p.lng, category: isEvent ? "event" : p.category, draft: needs, - kind: isEvent ? EVENT_KIND_PIN_KIND[p.eventKind ?? "cleanup"] : null, - tip: p.title, + kind: eventKind ? EVENT_KIND_PIN_KIND[eventKind] : null, + tip: pinTitle(p), place: p.place, status: p.status, flagged: p.flagged, - title: p.title, } } -type ActivePin = ReturnType +function statusTone(pin: LivePin): { color: string; label: string } { + if (pin.eventKind === "other_volunteer") { + return { color: "var(--moss-700)", label: `${eventKindLabel(pin.eventKind)} event` } + } + if (pin.eventKind) return { color: "var(--sun-700)", label: `${eventKindLabel(pin.eventKind)} event` } + if (pin.flagged) return { color: "var(--bloom-700)", label: "Flagged" } + const view = reportStatusView(pin.status) + return { color: view.tone, label: view.label } +} -const BUCKET_TONE: Record = { - submitted: "var(--ink-2)", - in_progress: "var(--lilac-600)", - completed: "var(--moss-700)", - removed: "var(--bloom-700)", +function pinStateClass(pin: LivePin): string { + if (pin.refType === "event") return "is-event" + return pin.draft ? "is-needs" : "is-routed" } -function statusTone(m: ActivePin): { color: string; label: string } { - if (m.refType === "event") return { color: "var(--sun-700)", label: "Cleanup event" } - if (m.flagged) return { color: "var(--bloom-700)", label: "Flagged" } - const bucket = reportBucketOf(m.status) - return { color: BUCKET_TONE[bucket], label: BUCKET_VIEW[bucket].label } +function ActivePinCard({ + pin, + onOpen, + onClose, +}: { + pin: LivePin + onOpen: () => void + onClose: () => void +}) { + const tone = statusTone(pin) + return ( +
+
+ +
+
+
{pin.tip}
+
+ {pin.place} + · + {tone.label} +
+
+ + +
+ ) +} + +function MapUnavailableCard({ onRetry }: { onRetry: () => void }) { + return ( +
+
+ +
+
+
Map unavailable
+
+ Could not load the live feed. +
+
+ +
+ ) +} + +function MapLegend() { + return ( +
+ + + Needs attention + + + + Handled + + + + Events + +
+ ) } export function LiveMap({ tint = "voyager" }: { tint?: MapTint }) { const nav = useNav() - const q = useHomeMap() - const [active, setActive] = React.useState(null) + const mapQuery = useHomeMap() + const [activeId, setActiveId] = React.useState(null) - const pins = React.useMemo(() => q.data?.pins ?? [], [q.data]) + const pins = React.useMemo(() => mapQuery.data?.pins ?? [], [mapQuery.data]) const markers = React.useMemo(() => pins.map(toMapPin), [pins]) + const active = markers.find((m) => m.id === activeId) ?? null const reportCount = pins.filter((p) => p.refType === "report").length const eventCount = pins.filter((p) => p.refType === "event").length const needsAttention = pins.filter( @@ -100,66 +176,19 @@ export function LiveMap({ tint = "voyager" }: { tint?: MapTint }) { pins={markers} tint={tint} activeId={active ? active.id : null} - onPinTap={(p) => setActive(p as ActivePin)} + onPinTap={(p) => setActiveId(p.id)} /> -
- - - Needs attention - - - - Handled - - - - Events - -
+ - {q.isError && !pins.length && ( -
-
- -
-
-
Map unavailable
-
- Could not load the live feed. -
-
- -
+ {mapQuery.isError && !pins.length && ( + mapQuery.refetch()} /> )} - {active && - (() => { - const tone = statusTone(active) - return ( -
-
- -
-
-
{active.title}
-
- {active.place} - · - {tone.label} -
-
- -
- ) - })()} +
+ {active && ( + setActiveId(null)} /> + )} +
) diff --git a/apps/admin/src/components/providers.test.tsx b/apps/admin/src/components/providers.test.tsx new file mode 100644 index 0000000..e6b5d92 --- /dev/null +++ b/apps/admin/src/components/providers.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, within } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { Providers } from "@/components/providers" + +vi.mock("@/components/auth/auth-hydrator", () => ({ AuthHydrator: () => null })) +const session = vi.hoisted(() => ({ + current: { isOperator: true, operator: null, status: "authenticated" } as { + isOperator: boolean + operator: null + status: string + }, +})) + +vi.mock("@/hooks/use-admin-auth", () => ({ + useOperatorSession: () => session.current, +})) + +function Crash(): never { + throw new Error("shell exploded") +} + +beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => undefined) +}) + +afterEach(() => { + vi.restoreAllMocks() + session.current = { isOperator: true, operator: null, status: "authenticated" } +}) + +describe("Providers", () => { + it("renders the operator's app content", () => { + render( + +

Dashboard content

+
, + ) + expect(screen.getByText("Dashboard content")).toBeInTheDocument() + }) + + it("catches a render error anywhere in the app with a root alert offering Reload", () => { + render( + + + , + ) + + const alert = screen.getByRole("alert") + expect(alert).toHaveTextContent("Something went wrong") + expect(alert).toHaveTextContent( + "This page hit an unexpected error. Try again, or reload the dashboard.", + ) + expect(alert).not.toHaveTextContent("shell exploded") + expect(within(alert).getByRole("button", { name: "Reload" })).toBeInTheDocument() + }) + + it("shows a signing-out screen rather than the sign-in gate while signing out", () => { + session.current = { isOperator: false, operator: null, status: "signing-out" } + render( + +

Dashboard content

+
, + ) + + expect(screen.getByRole("status")).toHaveTextContent("Signing out...") + expect(screen.queryByText("Dashboard content")).toBeNull() + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull() + }) +}) diff --git a/apps/admin/src/components/providers.tsx b/apps/admin/src/components/providers.tsx index 8b9b4aa..9a59c5e 100644 --- a/apps/admin/src/components/providers.tsx +++ b/apps/admin/src/components/providers.tsx @@ -5,16 +5,11 @@ import { QueryClientProvider, type QueryClient } from "@tanstack/react-query" import { makeQueryClient } from "@/lib/query" import { AuthHydrator } from "@/components/auth/auth-hydrator" +import { BrandBug } from "@/components/shared/brand-bug" +import { ErrorBoundary } from "@/components/shell/error-boundary" import { OperatorLogin } from "@/features/auth/operator-login" import { useOperatorSession } from "@/hooks/use-admin-auth" -/** - * App-wide client providers. Mounted once in the root layout. - * - * The QueryClient is created lazily and held in a ref so it survives re-renders but is unique per - * browser tab. AuthHydrator runs the Cloudflare Access exchange/bootstrap on mount; AuthGate then - * decides whether to render the dashboard, the loading screen, or the operator (Access) gate. - */ export function Providers({ children }: { children: React.ReactNode }) { const clientRef = React.useRef(null) if (!clientRef.current) { @@ -23,24 +18,23 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - - {children} + + + {children} + ) } -/** - * The operator gate. Only an authenticated operator session renders the dashboard: - * - idle / loading -> a minimal loading screen (Access exchange in flight, or pre-hydration). - * - not an operator -> the full-page Cloudflare Access gate (anonymous: authenticating + manual - * continue; forbidden: not-authorized message). - * - operator -> the dashboard shell (children). - */ function AuthGate({ children }: { children: React.ReactNode }) { const { isOperator, status } = useOperatorSession() if (status === "idle" || status === "loading") { - return + return + } + + if (status === "signing-out") { + return } if (!isOperator) { @@ -50,17 +44,14 @@ function AuthGate({ children }: { children: React.ReactNode }) { return <>{children} } -/** Minimal centered loading screen shown while the session check is in flight. */ -function BootScreen() { +function BootScreen({ label }: { label: string }) { return (
) } diff --git a/apps/admin/src/components/shared/backdrop-dismiss.ts b/apps/admin/src/components/shared/backdrop-dismiss.ts new file mode 100644 index 0000000..8271972 --- /dev/null +++ b/apps/admin/src/components/shared/backdrop-dismiss.ts @@ -0,0 +1,60 @@ +"use client" + +import * as React from "react" + +interface BackdropProps { + ref: React.RefObject + onClick: React.MouseEventHandler +} + +// A press that starts in the modal (a text-selection drag, typically) and is released over the +// backdrop must not dismiss: browsers dispatch that click on the nearest common ancestor of press and +// release, which is the backdrop itself when it wraps the modal. So only a click whose press also +// started on the backdrop counts. The press is read on the window in the capture phase because a +// backdrop that is a sibling of its panel never receives a press that began inside the panel. +export function useBackdropDismiss( + onDismiss: () => void, +): BackdropProps { + const ref = React.useRef(null) + const pressedOnBackdrop = React.useRef(false) + + React.useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + pressedOnBackdrop.current = ref.current !== null && e.target === ref.current + } + window.addEventListener("pointerdown", onPointerDown, true) + return () => window.removeEventListener("pointerdown", onPointerDown, true) + }, []) + + const onClick = (e: React.MouseEvent) => { + const dismiss = pressedOnBackdrop.current && e.target === e.currentTarget + pressedOnBackdrop.current = false + if (dismiss) onDismiss() + } + + return { ref, onClick } +} + +/** + * Escape and the backdrop dismiss a modal only while it holds nothing the operator would lose; once + * there is a draft, its close button and Cancel are the deliberate ways to discard it. The shell yields + * Escape to any open modal (escape-owner.ts), so Escape here closes only the modal and never navigates. + */ +export function usePristineDismiss( + onClose: () => void, + pristine: boolean, +): BackdropProps { + const dismiss = React.useCallback(() => { + if (pristine) onClose() + }, [pristine, onClose]) + + React.useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") dismiss() + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [dismiss]) + + return useBackdropDismiss(dismiss) +} diff --git a/apps/admin/src/components/shared/brand-bug.tsx b/apps/admin/src/components/shared/brand-bug.tsx new file mode 100644 index 0000000..3a3ba38 --- /dev/null +++ b/apps/admin/src/components/shared/brand-bug.tsx @@ -0,0 +1,7 @@ +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. + return +} diff --git a/apps/admin/src/components/shared/data-states.test.tsx b/apps/admin/src/components/shared/data-states.test.tsx new file mode 100644 index 0000000..f7b6a55 --- /dev/null +++ b/apps/admin/src/components/shared/data-states.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from "@testing-library/react" +import { AppError, ErrorCode } from "@civfix/shared" +import { describe, expect, it } from "vitest" + +import { ErrorState } from "@/components/shared/data-states" + +describe("ErrorState", () => { + it("shows the API's own message", () => { + render() + expect(screen.getByRole("alert")).toHaveTextContent("Operators only") + }) + + it("explains a failed request in plain words instead of the browser's fetch text", () => { + render() + const alert = screen.getByRole("alert") + expect(alert).toHaveTextContent("Could not reach the server. Check your connection and try again.") + expect(alert).not.toHaveTextContent("Failed to fetch") + }) + + it("shows the generic copy for a thrown non-error value", () => { + render() + expect(screen.getByRole("alert")).toHaveTextContent("Something went wrong. Please try again.") + }) + + it("lets an explicit message replace the error's", () => { + render() + expect(screen.getByRole("alert")).toHaveTextContent("Custom copy") + }) +}) diff --git a/apps/admin/src/components/shared/data-states.tsx b/apps/admin/src/components/shared/data-states.tsx index b8058f2..d37f2f3 100644 --- a/apps/admin/src/components/shared/data-states.tsx +++ b/apps/admin/src/components/shared/data-states.tsx @@ -2,23 +2,8 @@ import * as React from "react" -import { toAppError } from "@/lib/api" +import { errorMessage } from "@/lib/error-messages" -/** - * Standard loading / error / empty state components for data-bound views. The prototype had NONE of - * these (data was synchronous from window.DATA); every real list/detail needs them. The section pages - * use these so the conventions stay consistent across sections. - * - * Recommended pattern in a page/section: - * - * const q = useSomething(params) - * if (q.isLoading) return - * if (q.isError) return q.refetch()} /> - * if (!q.data?.items.length) return - * // ...render q.data - */ - -/** Centered spinner row for in-flight queries. */ export function LoadingState({ label = "Loading..." }: { label?: string }) { return (
@@ -28,50 +13,39 @@ export function LoadingState({ label = "Loading..." }: { label?: string }) { ) } -/** - * Error panel with an optional retry. Surfaces the normalized AppError message; when the backend is - * down this renders the friendly INTERNAL message rather than crashing. - */ export function ErrorState({ error, onRetry, title = "Could not load this", + message, + action, }: { error: unknown onRetry?: () => void title?: string + message?: string + action?: { label: string; onClick: () => void } }) { - const message = React.useMemo(() => toAppError(error).message, [error]) + const errorText = React.useMemo(() => errorMessage(error), [error]) + const retry = onRetry && ( + + ) return (
{title}
-
{message}
- {onRetry && ( - +
{message ?? errorText}
+ {action ? ( +
+ {retry} + +
+ ) : ( + retry )}
) } - -/** A simple skeleton block; size it with width/height. Use several to fake a loading list/card. */ -export function Skeleton({ - width = "100%", - height = 16, - radius = 8, - className = "", -}: { - width?: number | string - height?: number | string - radius?: number - className?: string -}) { - return ( -