Adopt B42's cell-edit overlay in B34 and fix the optimistic baseline again.

The Drawer half of MunicipalityPortfolioCell now goes through
CampaignCellEditOverlay: the container switches on a `variant` prop, not on
viewport, so the pointer policy stays in CSS while the cell inherits
`initialFocus` (the hand-rolled sheet focused the first chip, a link out of the
row being edited) and the ARIA trigger attributes every other cell has.

The optimistic baseline bug was back in a shape that reads as correct. Applying
the delta functionally is not enough when the fallback is not: `current ??
municipalityIds` still read the render that created the closure, which is the
branch an undo always takes, since the server reconcile clears the optimistic
value first. Undo after a later add rebuilt the row from a set that predated it,
permanently. The baseline is a ref now, advanced synchronously in the draft
branch, and pinned by a spec verified red against the old code.

Also: revert the Drawer body gate (its saving measured as zero, and it animated
an empty box out of view), collapse the word-start matcher to two string tests
now that both sides are normalized, copy the memoized território ids before
handing them to a caller, and revalidate every changed município of an advisor
batch instead of only the last.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 23:59:20 -03:00
parent 9f3b128ec2
commit a7bc05f3a1
10 changed files with 359 additions and 144 deletions

File diff suppressed because one or more lines are too long

View file

@ -4,31 +4,31 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
## Debt Ledger
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b |
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | recovered 2026-07-27 (B34): `aikido_full_scan` completed with 0 findings on the changed files; only taint analysis timed out. The outage was transient, so treat this row as a watch item — if exit 2 returns, the SCA gap (incl. `shpjs`) reopens | |
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 |
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
| Two e2e tests fail **deterministically at one worker on a clean `main` tree** (measured 2026-07-27 by stashing the whole B34 delivery and re-running): `campaignNearestMunicipality` "coordinator in Salvador is sent to the zone list" cannot find the `Salvador — ZE 1` link on `?q=Salvador`, and `campaignMunicipalities` "advisor opens a demand and decides it" cannot find its demand heading. These are **not** the 2-worker flake in the row above — retrying and lowering concurrency do not help — so the gate currently ships with two red specs whose failure nobody has diagnosed | `tests/e2e/campaignNearestMunicipality.e2e.spec.ts:156`, `tests/e2e/campaignMunicipalities.e2e.spec.ts:394` | broken test (or real regression) | high — a permanently red gate trains everyone to ignore it, which is how a real failure ships | S | **P1** | open 2026-07-27 (B34 `/simplify`) — start from the saved `test-results/**/error-context.md`: the Salvador list renders (zones ZE 5/9/10–15 are in the snapshot) but the assertion's exact accessible name is absent, so suspect the E9 `deficit` default sort plus pagination rather than the card | |
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | low since the `gru1` move (~2 ms each; it was ~120 ms each while functions ran in `iad1`) | M | P2 | open — measured 2026-07-26. A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins | |
| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ |
| `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover, its own `ResizeObserver` collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared `MunicipalityPortfolioCell` (always-mounted on `pointer-fine`, Drawer on `pointer-coarse`, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts | `src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx` vs `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | open 2026-07-27 (B34), **trigger fired** — the reuse review measured ~105 line-for-line duplicated lines of `ResizeObserver` collapse math between the two cells (constants, the first-callback guard, the `lastVisibleTop + 1` filter, the trailing-width loop, the `expandToggle`), including one verbatim comment. Scheduled as **B34+ F2** (`useChipRowOverflow`); the duplication predates B34 (it was born between `AdvisorMunicipalityCell` and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation | B34 |
| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | P2 | open 2026-07-27 (B34) — direction recommended in the fill-in (payload-minimal, since staff edit far more than they navigate), but it trades the DB's `region` for the snapshot-tested catalog's, so it needs an explicit call. A third, orthogonal lever: load the Drawer behind `next/dynamic` on first touch — it only opens on `pointer-coarse`. **Sibling, not duplicate:** B33+ registers the same catalog in `/campanha/municipios`'s bundle, but by accident (a predicate dragging the module) rather than by need, so the two fixes differ — pay them in one pass if at all, since the measurement bench is shared | B34+ |
| `AdvisorsTable` mixes an edit _mode_ (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once | `src/components/campaign/advisor/AdvisorsTable.tsx` | UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 |
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; `exports`/`types`/`enumMembers` at **error** in CI; tests entered the knip graph; `duplicates` off (only hits were the 6 deliberate access aliases `canUpdateX = canReadX`) | Pass 2 W4b |
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | recovered 2026-07-27 (B34): `aikido_full_scan` completed with 0 findings on the changed files; only taint analysis timed out. **Exit 2 did return in the same day's follow-up pass, and the useful detail is that it is not a total outage:** the wrapper reports the error while Opengrep's own summary says `Ran 316 rules on 1 file: 0 findings`, so SAST results are present and clean — the exit code tracks "some files were skipped or only partially analyzed", not a failed scan. Read the summary text before treating a red scan as a blocked gate. The SCA half (incl. `shpjs`) remains unverified | |
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 |
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
| E2E suite thin (7 specs; most campaign CRUD int-only) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| CI runs on GitHub mirror only (no Codeberg/Woodpecker pipeline) | `.github/workflows/ci.yml` | process | low | M | P3 | open | |
| E2E campaign journeys flake with 2 workers — **root cause narrowed 2026-07-26 (B13)**, and it is not only latency: (a) `campaignMunicipalities`/`campaign-pwa` click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean `main` tree, so it is not a B13 regression; both specs pass in isolation | `tests/e2e/campaignMunicipalities.e2e.spec.ts`, `tests/e2e/campaign-pwa.e2e.spec.ts` | test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
| Two e2e tests fail **deterministically at one worker on a clean `main` tree** (measured 2026-07-27 by stashing the whole B34 delivery and re-running): `campaignNearestMunicipality` "coordinator in Salvador is sent to the zone list" cannot find the `Salvador — ZE 1` link on `?q=Salvador`, and `campaignMunicipalities` "advisor opens a demand and decides it" cannot find its demand heading. These are **not** the 2-worker flake in the row above — retrying and lowering concurrency do not help — so the gate currently ships with two red specs whose failure nobody has diagnosed | `tests/e2e/campaignNearestMunicipality.e2e.spec.ts:156`, `tests/e2e/campaignMunicipalities.e2e.spec.ts:394` | broken test (or real regression) | high — a permanently red gate trains everyone to ignore it, which is how a real failure ships | S | **P1** | open 2026-07-27 (B34 `/simplify`) — start from the saved `test-results/**/error-context.md`: the Salvador list renders (zones ZE 5/9/10–15 are in the snapshot) but the assertion's exact accessible name is absent, so suspect the E9 `deficit` default sort plus pagination rather than the card | |
| Post listing cache holds all published posts, filters in memory | `src/utilities/posts.ts` | scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
| Petition `generateStaticParams` uses uncached `getDocuments` without `select`/`depth: 0` | `src/utilities/documents.ts` consumers | over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: `getPetitionIds` with `depth: 0` + empty `select`) | |
| Compare mode (`?compare=`) still queries per candidate uncached cross-request | `src/utilities/municipalityMapData.ts` | perf | low | M | P3 | open — React `cache()` dedups in-request; add `unstable_cache` if profiling warrants | |
| Map bundle serializes all years × scenarios (~100KB raw), **+63.6 KB after B13** (`competitiveRankByYear` is ~57 KB of it: three years × 417 codes × `{rank, candidates}`, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) | `municipalityMapContract.ts` consumers | payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as **B13+ F4** (roadmap fill-in) | B13+ F4 |
| Props→state `useEffect` mirrors in list controls/comboboxes | `MunicipalityListExpectedVotesControl`, `AsyncSearchCombobox`, `StrictCombobox` | pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
| User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, `SupportStatusBadge` summary, fallback/error strings, code comments) | `rg -n 'Praça' src` (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) | copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | open — scheduled Pass 2 W4f (consent texts stay for the legal batch) | Pass 2 W4f |
| Access checks re-read the actor per Local API call: `getFreshCampaignUser` caches in `req.context`, but a `payload.find({ user, overrideAccess: false })` that passes no `req` gets a fresh context, so one `/campanha/municipios` render issues ~5 identical `campaignUser` reads on top of the layout's own pair. **Re-measured 2026-07-27 (B34) on `/campanha/liderancas`, where it is an order of magnitude worse:** `payload.find` on `leadership` at `depth: 1` with an actor and `overrideAccess: false` costs **371 statements / 185 ms**, of which **367 are the identical `select … from "campaign_user"`** — the same read once per document _and_ per populated relation. The controls isolate the cause: `overrideAccess: true` is **4 SQL / 11 ms**, and `depth: 0` with the actor is 103 SQL / 47 ms, so it is the access layer on the populate path getting a `req` that does not carry the memo | `src/utilities/access/shared.ts` + every `overrideAccess: false` caller | perf (round-trip fan-out) | medium — 185 ms of a 229 ms page load, and B34 made that page one the mesa re-renders on every chip toggle (see F7); each statement is ~2 ms local but crosses `gru1`↔`sa-east-1` in production | M | **P1** | open — measured 2026-07-26, re-measured 2026-07-27 (B34). A React `cache()` layer would dedupe across requests, but it changes actor freshness inside server actions that mutate roles (`reloadUnrestrictedActor`), so it needs its own delivery with pins. Cheap adjacent win found in the same pass: `getAdvisorMunicipalityIds` (2 SQL / 10,7 ms) is a textbook `cache()` candidate — same request, same args, no dedup — and B34 added a call site in `liderancas/page.tsx` | |
| `authenticateCampaignToken` reads the actor twice: Payload's JWT strategy already fetches the user document, then `findByID(depth: 1)` re-reads it — the second read's only unique contribution is populating `avatar` for the sidebar | `src/utilities/campaignAuth.ts` | perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on `removePrivateAuthFields` and on role-downgrade freshness | RS+ |
| `LeadershipStateDeputyRelationCell` (B31) keeps its own Popover, its own `ResizeObserver` collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared `MunicipalityPortfolioCell` (always-mounted on `pointer-fine`, Drawer on `pointer-coarse`, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts | `src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx` vs `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | open 2026-07-27 (B34), **trigger fired** — the reuse review measured ~105 line-for-line duplicated lines of `ResizeObserver` collapse math between the two cells (constants, the first-callback guard, the `lastVisibleTop + 1` filter, the trailing-width loop, the `expandToggle`), including one verbatim comment. Scheduled as **B34+ F2** (`useChipRowOverflow`); the duplication predates B34 (it was born between `AdvisorMunicipalityCell` and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation. **Re-measured post-rebase: ~165 lines, not ~105** — the twin also copies the `sameIdSet` prop adoption, the `showAll`/`measuring`/`clamping` derivation, the `expandToggle`, and the optimistic delta with per-delta rollback and toast, so what comes out is `useRelationChipCell` (measurement **plus** the optimistic reducer). Which means **B37 does not retire this**: whoever deletes `LeadershipStateDeputyRelationCell` does. The drift is now three-way in one row — that cell opens a **Popover** from a pencil on touch, while the column beside it opens a Drawer, and the Status column's own comment asserts the table is Popover-only | B34 |
| B34 shipped a **measured** First Load JS regression: `/campanha/liderancas` 246 → **304 kB** and `/campanha/assessores` 253 → **295 kB** (Next's minified figure, two full builds against a stashed clean `main`). Assessores already imported the static Bahia catalog, so its +42 kB is the **cell** (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes **oppose each other**: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to `{id,slug}` keeps the bundle | `src/lib/municipalityPortfolio.ts`, `src/utilities/municipalityPortfolioIndex.ts`, `src/components/campaign/shared/MunicipalityPortfolioCell.tsx` | perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | P2 | open 2026-07-27 (B34) — direction recommended in the fill-in (payload-minimal, since staff edit far more than they navigate), but it trades the DB's `region` for the snapshot-tested catalog's, so it needs an explicit call. A third, orthogonal lever: load the Drawer behind `next/dynamic` on first touch — it only opens on `pointer-coarse`. **Sibling, not duplicate:** B33+ registers the same catalog in `/campanha/municipios`'s bundle, but by accident (a predicate dragging the module) rather than by need, so the two fixes differ — pay them in one pass if at all, since the measurement bench is shared | B34+ |
| `AdvisorsTable` mixes an edit _mode_ (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once | `src/components/campaign/advisor/AdvisorsTable.tsx` | UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 |
## Smell Inventory

View file

@ -14,10 +14,52 @@ O passe de perf localizado que fechou o B34 rendeu, com bancada em `tsx` sobre o
| idem, em re-render (índice quente) | 4,2 ms | **0,043 ms** | o `WeakMap` sobrevive entre renders; um `useMemo` por linha não sobreviveria |
| `searchMunicipalityPortfolio`, tecla | 4,59 ms | **0,077 ms** | query normalizada **uma vez** + rótulos pré-normalizados (`matchesNormalizedAtWordStart`) |
Também entraram: `sameIdSet` no lugar do comparador de chaves ordenadas escrito à mão (3º call site do helper), `scrollIntoView` restrito a navegação por teclado (o mouse já está onde aponta — era um layout forçado por opção percorrida), corpo do `Drawer` atrás de `drawerOpen` (era alocado em toda linha, inclusive no ponteiro fino onde nunca abre), e o `revalidatePath` passou a usar o delta real (`changed`) em vez de tudo que foi pedido — remover um município que já não estava vinculado não busta mais a rota dele.
Também entraram: `sameIdSet` no lugar do comparador de chaves ordenadas escrito à mão (3º call site do helper), `scrollIntoView` restrito a navegação por teclado (o mouse já está onde aponta — era um layout forçado por opção percorrida), e o `revalidatePath` passou a usar o delta real (`changed`) em vez de tudo que foi pedido — remover um município que já não estava vinculado não busta mais a rota dele.
**Um item do relatório foi rejeitado por medição, e vale registrar porque parecia óbvio:** remover o `sort: 'name'` do loader ("nada depende da ordem do banco") **quebrou a lista de sugestões**. `searchMunicipalityPortfolio` percorre o índice na ordem em que ele chega e para no `limit`, então a ordem alfabética das sugestões era a ordem do `ORDER BY` — sem ele o usuário passa a receber os 12 primeiros casamentos em ordem física de linha. O e2e do B34 pegou (o teste digita um prefixo e aperta Enter no primeiro item), e o `sort` voltou com o motivo escrito ao lado.
## Fechado no `/simplify` pós-rebase (a premissa que morreu)
O rebase em `main` trouxe o **B42 ✓** e, com ele, `CampaignCellEditOverlay`. A leitura inicial foi que a casca não servia porque "ela decide por largura de viewport e o B34 decide por tipo de ponteiro". **Isso é falso no código:** a casca não decide nada — `variant` é prop, não há `matchMedia` nela, e o docstring diz isso com todas as letras. Quem decide por largura é o `MunicipalityList`, que renderiza duas árvores irmãs; `LeadershipListSupportStatusControl`, na mesma tabela do B34, fixa `variant="popover"` porque sabe a sua própria política. O B34 passou a ser a terceira política sobre um container que não tem nenhuma: **`variant="sheet"` fixo, com o `pointer-fine:hidden` no `triggerClassName`**, e a política de ponteiro continua onde estava, no CSS.
Ganhos que não eram sobre linhas (embora sejam ~25 a menos, ~14 delas cópias verbatim do `DrawerHeader`/`DrawerFooter`): o `initialFocus={titleRef}` da casca — o Drawer do B34 focava o **primeiro chip**, um link que navega para fora da linha que se está editando, ou o campo de busca numa linha em rascunho, que é exatamente o teclado-virtual-sobre-a-folha que o B42 documentou; o trigger passou a ter `aria-haspopup="dialog"`/`aria-expanded`/`aria-busy`, que todo outro trigger de célula das duas listas já tinha; e a célula entrou no spec table-driven da casca como **5º caso** — a metade Drawer do B34 não tinha nenhuma cobertura, porque o e2e roda em ponteiro fino.
**A adoção custou um bug real, achado pelo e2e e vale saber antes de adotar a casca em outro lugar:** um `Drawer` sempre montado reporta `onOpenChange(false)` quando uma dispensa em outro ponto da página varre a pilha de camadas — não só quando ele mesmo fecha. O handler novo (limpar a busca ao fechar a folha, para que o listbox portalizado não apareça num toque depois que a gaveta sai) apagava, sem guarda, o que o ponteiro **fino** tinha acabado de digitar no input inline. O guard é `if (!next && drawerOpen)`.
Também fechado aqui, e é correção de comportamento, não de estilo: `setAdvisorMunicipalitiesBatch` revalidava só o **último** município do lote (`lastSlug`). Era pré-existente do B19, mas o B34 promoveu o lote ao **único** caminho de escrita do assessor e pôs um chip de território ao alcance do dedo, então soltar um TI de 30 municípios deixava 29 páginas de detalhe velhas. Agora devolve `slugs: string[]` e revalida a lista uma vez e cada detalhe que mudou, espelhando `revalidateLeadershipMunicipalityPaths`.
**Duas recomendações do relatório foram fechadas como "não fazer", com motivo:** o ramo **Popover** da casca não serve ao B34 e não deve ser adotado — ele entrega `role="dialog"` atrás de um `PopoverTrigger`, e um dialog não pode ser o popup de um combobox ARIA 1.2 (o inline precisa de `PopoverAnchor` + `role="listbox"` que nunca toma foco). E o **F3** abaixo (colapsar os três `next*IdsAfterMembership`) continua registrado, mas com a recomendação invertida por uma segunda leitura: um genérico cobrindo "um id" e "um conjunto com piso e teto" precisaria de quatro callbacks para poupar ~20 linhas.
## Fechado na revisão de qualidade + performance (o bug que voltou)
**O achado que importa: o bug do baseline otimista tinha voltado, escrito de outro jeito.** O fechamento anterior corrigiu "aplicar o delta funcionalmente" e registrou isso como resolvido — mas o `??` _dentro_ do updater ainda lia o `municipalityIds` da render que criou a closure, e o "Desfazer" do toast é justamente o único chamador que sobrevive à sua render. Sequência real, reproduzida em teste: remover um território (toast aparece) → servidor reconcilia, `optimistic` volta a `null` → **adicionar outro município** → apertar Desfazer. O updater cai no ramo do fallback, reconstrói a linha a partir do conjunto de antes do add, e o município adicionado some da tela enquanto continua salvo no servidor. A divergência é **permanente**, porque o efeito de reconciliação só limpa em igualdade exata. Corrigido com um ref (`latestIds`) que segue `effectiveIds`, e o ramo `draft` o avança **de forma síncrona** — dois toggles no mesmo tick liam ambos a prop da render em que foram enfileirados, e o segundo descartava o primeiro.
Isso agora tem pino: `tests/unit/municipalityPortfolioCell.unit.spec.ts` monta a sequência inteira e falha (vermelho verificado) contra a versão com o bug. As duas revisões **discordaram** aqui — a de performance leu o `withDelta` funcional e concluiu "sem divergência de closure"; a de qualidade traçou a sequência. Vale registrar qual estava certa, porque a leitura otimista é a intuitiva.
**Uma otimização anterior foi desfeita por medição, e ela era minha:** o corpo do `Drawer` estava atrás de `drawerOpen` como "ganho de alocação". O ganho foi medido e é **zero** — o portal fechado não emite DOM e a string de 85 classes é acerto de cache do tailwind-merge (2,71 ms frios uma vez por sessão, 0,0001 ms depois) — enquanto o custo é real: base-ui mantém o popup montado durante a transição de saída, então fechar a gaveta animava uma caixa **vazia** encolhendo. As outras duas células em célula da casa não fazem esse gate. Removido.
Também entraram: `matchesNormalizedAtWordStart` deixou de fatiar/juntar palavra por palavra — como `normalizeSearchPhrase` emite exatamente `words.join(' ')`, "começo de palavra" é a posição 0 ou depois de um espaço, então `startsWith(q) || includes(' ' + q)` diz o mesmo em **0,0011 ms contra 0,0585 ms** por 435 rótulos (53×, equivalência verificada em 4.608 pares com apóstrofo, travessão, dígito e vazio); `matchesAtWordStart` normaliza a query **primeiro** e sai cedo na vazia, que é como os pickers filtram a lista inteira ao abrir; e `buildMunicipalityPortfolioChips` passou a **copiar** o `municipalityIds` do território ao montar o chip — aquele array é memoizado por índice e compartilhado por toda a tabela, então entregá-lo a um chamador transformava qualquer `sort`/`splice` posterior em corrupção da página inteira.
## F7 — Cada toggle de chip re-renderiza a página inteira (374 SQL, 229 ms)
`setLeadershipMunicipalitiesMembership` chama `revalidatePath('/campanha/liderancas', 'page')` a cada toggle, e a célula grava **uma action por toggle**. O Next re-executa a página dentro da resposta da action: medido localmente, com 288 lideranças, **374 statements e 228,6 ms** (`loadLeadershipListPageData` 371/183,3 ms + `loadStateDeputyOptions` 2/4,4 ms + `loadMunicipalityPortfolioIndex` 1/11,9 ms), mais ~44 kB de payload por toggle. Limpar um chip de território e adicionar três dispara isso quatro vezes — e localhost é **piso**: `gru1`↔`sa-east-1` soma ~1–3 ms por round trip.
A célula já reconcilia contra o array do servidor e já faz rollback por delta em falha, então o refresh da lista não entrega nada que o usuário veja. Direção recomendada: dispensar o `revalidatePath` da própria lista no caminho de sucesso (mantendo o dos detalhes de município, que mudam de verdade) e deixar o estado otimista de pé até navegação. Não foi feito agora porque é mudança de comportamento em caminho de escrita e merece a sua própria medição pós-fix.
## F8 — O índice de municípios é dado de referência lido sem cache
`loadMunicipalityPortfolioIndex` custa **12,4 ms medianos (1 SQL)** e roda uma vez por request em duas rotas — e de novo em cada re-render disparado pelo F7. **~10 dos 12 ms são o transform por documento do Payload**, não a query: pedir só `slug` no `select` não muda nada (10,7 ms).
Contra a escada de cache do repo: o degrau 1 (`cache()`) rende **zero** — é um único call site por request, dentro de um `Promise.all`. O degrau 2 (`unstable_cache` + tag) é o encaixe certo e não está em uso: nome/slug/região de município é geografia read-only semeada por migration, a definição de dado lento entre requests. Uma tag `municipality-catalog` no allowlist de `revalidateRequest.ts` tira 12 ms das duas rotas e de todo toggle.
**Correção ao F1:** a opção (b) "payload mínimo" encolhe o payload RSC de **35.510 B / 7.286 B gzip** para **13.610 B / 3.534 B gzip**, mas **não poupa tempo de servidor nenhum** (10,7 ms de qualquer jeito). A decisão registrada é payload-contra-bundle, não um ganho de servidor.
## F9 — O ramo do assessor entrega uma chave nova ao WeakMap por linha
`searchIndex` filtra `municipalityIndex` quando há `addableIds`, produzindo **um array novo por linha** — e é esse array que vira a chave do WeakMap de derivações. O assessor fica com 25 conjuntos de derivação (4 Maps cada) em vez de um, e paga o build O(435) de novo na primeira busca de cada linha: **1,89 ms** para as 25 contra 0,076 ms para uma. É exatamente o modo de falha que a memoização foi escrita para evitar, embora o número absoluto seja pequeno.
Duas saídas, nenhuma feita agora: passar `addableIds` como opção de `searchMunicipalityPortfolio` (preserva a contagem escopada de hits de TI/ZE e elimina as 25 alocações, mas mexe na assinatura de uma função compartilhada), ou memoizar o filtro num WeakMap aninhado dentro da célula (contido, mas é uma segunda camada de cache).
## F1 — Regressão de First Load JS nas duas listas (prioritário)
**Medido** contra `main` limpo (stash com `-u`, dois `pnpm build` completos, números como o Next reporta — minificados, não gzip):
@ -27,6 +69,8 @@ Também entraram: `sameIdSet` no lugar do comparador de chaves ordenadas escrito
| `/campanha/liderancas` | 246 kB | **304 kB** | **+58 kB** |
| `/campanha/assessores` | 253 kB | **295 kB** | **+42 kB** |
**Ajuste pós-rebase (medido nas duas pontas, não inferido):** adotar `CampaignCellEditOverlay` mexeu nisto, e para os dois lados. O chunk **próprio** de cada rota encolheu (assessores 6,58 → **4,05 kB**; lideranças 6,89 → **6,46 kB**), mas o First Load de assessores subiu 295 → **297 kB**, enquanto lideranças ficou em **305 kB**. A assimetria tem uma causa só: a casca arrasta `CampaignHoverTooltip` (o ramo Popover a usa para a tooltip do B23) para uma rota que ainda não tinha nenhum chamador de overlay — lideranças já tinha, pela coluna Status. Não muda a recomendação abaixo; muda a linha de base contra a qual ela será medida.
A decomposição importa e ninguém deve re-derivá-la: `/campanha/assessores` **já** importava o catálogo estático da Bahia antes do B34 (via `advisorMunicipalityPortfolio.ts`, o módulo que virou `municipalityPortfolio.ts`), então os +42 kB de lá são a **célula em si** — Drawer, links, o contrato de combobox, a máquina de medição. Os +16 kB restantes em lideranças são o catálogo (`municipalityCatalog` + `bahiaTerritories` + `bahiaTseZones`, ~40 kB min / ~12,9 kB gzip) chegando a uma rota que não o tinha. É exatamente o custo que o **B14 ✓** já havia medido e nomeado (importar um serializador de URL no cliente custou 21 kB por um link).
**Duas direções, e elas se opõem — escolher, não fazer as duas:**
@ -46,6 +90,8 @@ Um terceiro lance, **ortogonal e cumulativo**: carregar o `Drawer` por `next/dyn
A duplicação é **anterior** ao B34 (nasceu entre `AdvisorMunicipalityCell` e a célula do B31; esta entrega moveu uma das cópias para `shared/`), mas o `AGENTS.md` do B34 escreveu que "**ele**, não o B37, é o gatilho de extração" — e o gatilho disparou. Extrair `useChipRowOverflow({ chipsKey, chipAttribute, toggleAttribute, trailingWidth, gapPx, enabled })` para `shared/`; a única diferença real entre os dois chamadores é o que ocupa o slot final (um botão de lápis com `ref` contra o `min-width` calculado do input), que vira o parâmetro.
**A releitura pós-rebase alargou o alvo: são ~165 linhas, não ~105, e o nome está errado.** Além da medição, os dois arquivos repetem a adoção de props por `sameIdSet` (~7/~12), o derivado `showAll`/`measuring`/`clamping` (~11/~15), o `expandToggle` (~32/~20) **e** o delta otimista com rollback por delta e toast (~39/~48). O que sai é `useRelationChipCell` — medição **mais** o redutor otimista —, e as duas metades da medição são idênticas até o `+ 1` de epsilon no `lastVisibleTop`. Consequência de planejamento: **o B37 não retira este débito**. B37 pluga uma terceira relação na célula compartilhada sem máquina nova; quem fecha o F2 é quem deletar `LeadershipStateDeputyRelationCell`, que é o módulo do qual a cópia sobreviveu.
## F3 — Um só delta de membership
`nextMunicipalityIdsAfterLeadershipMembership` é o **terceiro** módulo da família, e a generalização foi **verificada exata**, não aproximada: chamá-lo com um array de um elemento reproduz a semântica dos dois irmãos (`nextStateDeputyIdsAfterMembership`, `nextAdvisorIdsAfterMembership`) — `assigned === alreadyAssigned → null` equivale a `added.length === 0 → null`, e o teto checado antes do append equivale ao checado depois para um id. A única capacidade que os irmãos não têm é o piso, que já é opcional na forma. Colapsar em `nextRelationMembershipIds(current, changed, assigned, { max, capMessage, min, floorMessage })`, com os três nomes como wrappers de uma linha se o vocabulário importar; os três specs unitários viram um.
@ -65,6 +111,7 @@ São ~90 linhas de teclado/ARIA mais ~20 de atributos duplicados entre as duas s
## F6 — Baixos
- **`PortfolioChipLabel`**: o ramo `chip.kind === 'municipality' ? <Badge asChild><Link/></Badge> : <Badge/>` está escrito duas vezes (~14 linhas) nas duas superfícies. O resto da divergência entre elas é a tese da entrega (16 px revelado no hover contra 44 px sempre visível) e deve continuar divergindo.
- **`searchInputProps(surface)`**: o input de busca está escrito nas duas superfícies com **oito** props idênticas (`role`, `aria-expanded`, `aria-controls`, `aria-autocomplete`, `aria-activedescendant`, `onKeyDown`, `aria-label` e o corpo `setQuery` + `setFeedback(null)`), divergindo só no `setOpen(true)`/`onFocus`/`onBlur` do inline. A divergência é **correta**, e é justamente por isso que deveria ser explícita: uma factory espalhada nos dois, com os overrides do inline escritos depois do spread, poupa ~16 linhas e troca uma assimetria inferida por uma declarada.
- **e2e**: `createStaffLeadership({ role, municipalities })` e `expectPostResponse(page, fragment)` em `campaignE2EFixtures.ts` — 3 call sites hoje.
- **`relationIds()`** exportado de `tests/helpers/campaignFixtures.ts`: `typeof entry === 'number' ? entry : entry.id` está reescrito em 9 lugares nos int specs, com `src/utilities/relationship.ts` já exportando o equivalente de produção. Débito anterior; o spec novo é a 9ª instância.
- **`loadMunicipalityPortfolioIndex` fora da escada de cache**: leitura do catálogo inteiro, de campos só-geografia, idêntica para todo ator e toda request, sem `cache()` nem `unstable_cache`. Uma chamada por request hoje, então o degrau 1 não compra nada — mas o degrau 2 é a resposta da escada. Decidir, não deixar por omissão.

File diff suppressed because one or more lines are too long

View file

@ -183,7 +183,10 @@ export const setAdvisorMunicipalitiesBatchRecord = async (
uniqueMunicipalityIds.map((id) => `municipality-advisors:${id}`),
)
let lastSlug: string | undefined
// Every município actually changed, not just the last one: since B34 this
// is the only advisor write path, and a território chip moves up to 30 in
// one call — revalidating one would leave 29 detail pages stale.
const changedSlugs: string[] = []
for (const municipalityId of uniqueMunicipalityIds) {
const municipality = await payload.findByID({
@ -209,10 +212,10 @@ export const setAdvisorMunicipalitiesBatchRecord = async (
overrideAccess: true,
req,
})
if (typeof municipality.slug === 'string') lastSlug = municipality.slug
if (typeof municipality.slug === 'string') changedSlugs.push(municipality.slug)
}
return { slug: lastSlug }
return { slugs: changedSlugs }
},
{ beginFailureMessage: 'Não foi possível atualizar a carteira do assessor.' },
)
@ -221,7 +224,10 @@ export const setAdvisorMunicipalitiesBatchRecord = async (
export const setAdvisorMunicipalitiesBatch = async (input: AdvisorMunicipalitiesBatchInput) => {
const { payload, actor } = await getCampaignActionContext()
const result = await setAdvisorMunicipalitiesBatchRecord(payload, actor, input)
revalidateMunicipalityListPaths({ slug: result.slug })
revalidateMunicipalityListPaths({ scope: 'list' })
for (const slug of result.slugs) {
revalidateMunicipalityListPaths({ slug, scope: 'detail' })
}
revalidateAdvisorPaths(input.advisorId)
return result
}

View file

@ -14,17 +14,9 @@ import {
} from 'react'
import { toast } from 'sonner'
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
import {
@ -157,6 +149,19 @@ export const MunicipalityPortfolioCell = ({
const effectiveIds = optimistic ?? municipalityIds
/**
* The baseline a deferred delta falls back to, which cannot be the render's
* `municipalityIds`: the toast's "Desfazer" outlives the render that created
* it, and by the time it fires the optimistic value has usually reconciled to
* null — so the functional updater takes the fallback branch and would rebuild
* the row from a set that predates every edit made since. That divergence is
* permanent, because the reconcile effect above only clears on an exact match.
*/
const latestIds = useRef(municipalityIds)
useEffect(() => {
latestIds.current = effectiveIds
}, [effectiveIds])
const assignedIds = useMemo(() => new Set(effectiveIds), [effectiveIds])
const chips = useMemo(
() => buildMunicipalityPortfolioChips(effectiveIds, municipalityIndex),
@ -286,8 +291,16 @@ export const MunicipalityPortfolioCell = ({
const commit = (changedIds: number[], assigned: boolean) => {
if (changedIds.length === 0) return
if (draft) onDraftChange?.(withDelta(effectiveIds, changedIds, assigned))
else setOptimistic((current) => withDelta(current ?? municipalityIds, changedIds, assigned))
if (draft) {
// Advanced synchronously as well: the parent holds the draft in state, so
// two toggles batched into one tick would otherwise both read the prop of
// the render they were queued from and the second would drop the first.
const next = withDelta(latestIds.current, changedIds, assigned)
latestIds.current = next
onDraftChange?.(next)
} else {
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, assigned))
}
setQuery('')
setOpen(false)
setFeedback(null)
@ -316,7 +329,7 @@ export const MunicipalityPortfolioCell = ({
}
// Undo only THIS delta: reverting to the server baseline would also wipe
// a sibling toggle from the same burst that already saved.
setOptimistic((current) => withDelta(current ?? municipalityIds, changedIds, !assigned))
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, !assigned))
const message = result.message ?? updateErrorMessage
setFeedback({ kind: 'error', message })
toast.error(message)
@ -685,100 +698,106 @@ export const MunicipalityPortfolioCell = ({
* Coarse pointer: the cell is one big trigger, because a thumb scrolling a
* table cannot be asked to tell "open the município" from "delete it". The
* links it covers come back inside the Drawer.
*
* `variant` is hardcoded because the container takes its policy from the
* call site (B42) — and here the policy is the `pointer:` media query on
* the trigger, not the viewport, so a touch laptop gets this sheet and a
* tablet with a mouse gets the inline editor above. `hover:bg-transparent`
* undoes the shared class's hover: this trigger is an invisible pane over
* the whole cell, and tinting it would tint the chips it covers.
*/}
<button
type="button"
className="absolute inset-0 rounded-md focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none pointer-fine:hidden"
aria-label={`Editar municípios de ${ownerName}`}
onClick={(event) => {
event.stopPropagation()
setDrawerOpen(true)
<CampaignCellEditOverlay
variant="sheet"
open={drawerOpen}
// The container's trigger lets the click bubble to the cell root, which
// opens the inline suggestions — harmless while the sheet is up, but it
// would surface a portaled listbox on a touch device the moment the
// sheet closed. Clearing the search on close settles both.
//
// Guarded on the sheet having actually been open: a closed Drawer still
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
// sweeps the layer stack, and unguarded this erased whatever the fine
// pointer had typed into the inline input.
onOpenChange={(next) => {
setDrawerOpen(next)
if (!next && drawerOpen) closeSuggestions()
}}
/>
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen}>
<DrawerContent>
{/* Eager JSX: allocated on every render of every row, including the fine
* pointer where this never opens. The portal already skips the DOM. */}
{drawerOpen ? (
<>
<DrawerHeader>
<DrawerTitle>{drawerTitle}</DrawerTitle>
<DrawerDescription>{ownerName}</DrawerDescription>
</DrawerHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 pb-2">
{chips.length === 0 ? (
<p className="text-sm text-muted-foreground">Nenhum município vinculado.</p>
) : (
<ul className="flex flex-col gap-1">
{chips.map((chip) => {
const floorReason = removalFloorReason(chip)
return (
<li
key={chip.key}
className="flex min-h-11 items-center justify-between gap-2 rounded-md border px-2"
>
{chip.kind === 'municipality' ? (
<Link
href={`/campanha/municipios/${chip.slug}`}
className="flex min-h-11 min-w-0 flex-1 items-center underline-offset-4 hover:underline"
>
<Badge variant="secondary" className="min-w-0 font-normal">
{chipBody(chip)}
</Badge>
</Link>
) : (
<Badge variant="secondary" className="min-w-0 gap-1 font-normal">
{chipBody(chip)}
</Badge>
)}
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-11 shrink-0', floorReason && 'opacity-50')}
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
onClick={() => attemptRemove(chip)}
>
<XIcon className="size-4" aria-hidden="true" />
</Button>
</li>
)
})}
</ul>
)}
<Input
value={query}
role="combobox"
aria-expanded={drawerSuggesting}
aria-controls={listboxId('drawer')}
aria-autocomplete="list"
aria-activedescendant={drawerSuggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
}}
onKeyDown={onSearchKeyDown}
placeholder={SEARCH_PLACEHOLDER}
aria-label={SEARCH_LABEL}
className="min-h-11"
/>
{searching ? (
<div className="flex flex-col gap-1">{suggestionList('drawer')}</div>
) : null}
</div>
<DrawerFooter>
<DrawerClose
render={<Button type="button" variant="outline" className="min-h-11 w-full" />}
>
Fechar
</DrawerClose>
</DrawerFooter>
</>
) : null}
</DrawerContent>
</Drawer>
title={drawerTitle}
description={ownerName}
trigger={null}
triggerLabel={`Editar municípios de ${ownerName}`}
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
triggerBusy={isPending}
sheetBodyClassName="gap-3"
>
{/*
* Rendered unmounted, like the repo's other two in-cell sheets: base-ui
* keeps the popup mounted through a ~450 ms exit transition, so gating
* this on `drawerOpen` animated an empty collapsing box out of view. The
* allocation it saved was measured and is not a cost — the closed portal
* emits no DOM, and the class string is a tailwind-merge cache hit.
*/}
<>
{chips.length === 0 ? (
<p className="text-sm text-muted-foreground">Nenhum município vinculado.</p>
) : (
<ul className="flex flex-col gap-1">
{chips.map((chip) => {
const floorReason = removalFloorReason(chip)
return (
<li
key={chip.key}
className="flex min-h-11 items-center justify-between gap-2 rounded-md border px-2"
>
{chip.kind === 'municipality' ? (
<Link
href={`/campanha/municipios/${chip.slug}`}
className="flex min-h-11 min-w-0 flex-1 items-center underline-offset-4 hover:underline"
>
<Badge variant="secondary" className="min-w-0 font-normal">
{chipBody(chip)}
</Badge>
</Link>
) : (
<Badge variant="secondary" className="min-w-0 gap-1 font-normal">
{chipBody(chip)}
</Badge>
)}
<Button
type="button"
variant="ghost"
size="icon"
className={cn('size-11 shrink-0', floorReason && 'opacity-50')}
aria-disabled={floorReason !== null || undefined}
aria-label={chipRemoveLabel(chip, floorReason)}
onClick={() => attemptRemove(chip)}
>
<XIcon className="size-4" aria-hidden="true" />
</Button>
</li>
)
})}
</ul>
)}
<Input
value={query}
role="combobox"
aria-expanded={drawerSuggesting}
aria-controls={listboxId('drawer')}
aria-autocomplete="list"
aria-activedescendant={drawerSuggesting ? activeOptionId : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value)
setFeedback(null)
}}
onKeyDown={onSearchKeyDown}
placeholder={SEARCH_PLACEHOLDER}
aria-label={SEARCH_LABEL}
className="min-h-11"
/>
{searching ? <div className="flex flex-col gap-1">{suggestionList('drawer')}</div> : null}
</>
</CampaignCellEditOverlay>
<p className="sr-only" role="status" aria-live="polite">
{statusMessage}

View file

@ -167,7 +167,10 @@ export const buildMunicipalityPortfolioChips = (
key: `territory:${territory}`,
label: territory,
territory,
municipalityIds: territoryIds,
// Copied out of the derivation: that array is memoized per index and
// shared by every row of the table, so handing it to a caller would turn
// any later sort/splice on a chip into corruption of the whole page.
municipalityIds: [...territoryIds],
})
for (const id of territoryIds) remaining.delete(id)
}

View file

@ -17,10 +17,19 @@ export const matchesNormalizedAtWordStart = (
normalizedQuery: string,
): boolean => {
if (!normalizedQuery) return true
return normalizedLabel
.split(/[^\p{Letter}\p{Number}]+/u)
.some((_, index, words) => words.slice(index).join(' ').startsWith(normalizedQuery))
// `normalizeSearchPhrase` emits exactly `words.join(' ')` — alphanumeric runs,
// single-spaced, trimmed — so a word starts at position 0 and nowhere but
// after a space. Splitting the label and re-joining every suffix said the same
// thing while allocating an array and a string per word, per candidate.
return (
normalizedLabel.startsWith(normalizedQuery) || normalizedLabel.includes(` ${normalizedQuery}`)
)
}
export const matchesAtWordStart = (label: string, query: string): boolean =>
matchesNormalizedAtWordStart(normalizeSearchPhrase(label), normalizeSearchPhrase(query))
export const matchesAtWordStart = (label: string, query: string): boolean => {
// Normalizing the query first: an empty one matches everything, and callers
// filter their whole option list with it every time a picker opens.
const normalizedQuery = normalizeSearchPhrase(query)
if (!normalizedQuery) return true
return matchesNormalizedAtWordStart(normalizeSearchPhrase(label), normalizedQuery)
}

View file

@ -7,6 +7,7 @@ import { MunicipalityListExpectedVotesControl } from '@/components/campaign/muni
import { MunicipalityListLevelControl } from '@/components/campaign/municipality/MunicipalityListLevelControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import type { CampaignCellEditOverlayVariant } from '@/components/campaign/shared/CampaignCellEditOverlay'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
/**
@ -157,4 +158,39 @@ describe('campaign cell edit overlay', () => {
expect(document.querySelector('[data-slot="drawer-description"]')).toBeNull()
},
)
/**
* B34's cell is the one caller that is a sheet at EVERY viewport: it picks its
* surface by `pointer:`, not by width, so it has no `variant` to sweep and
* cannot join the cases above. It is here for the half it does share — the
* sheet chrome and, above all, `initialFocus`: its own Drawer used to hand
* focus to the first chip, a link that navigates away from the row being
* edited.
*/
it('opens the município portfolio in a Drawer focused on its title', async () => {
render(
createElement(MunicipalityPortfolioCell, {
ownerId: 1,
ownerName: 'Maria Souza',
municipalityIds: [11],
municipalityIndex: [
{ id: 11, name: MUNICIPALITY_NAME, slug: 'feira-de-santana', region: 'Portal do Sertão' },
],
commitAction: async () => ({}),
drawerTitle: 'Editar municípios',
updateErrorMessage: 'Não foi possível atualizar os municípios.',
}),
)
fireEvent.click(screen.getByRole('button', { name: 'Editar municípios de Maria Souza' }))
const dialog = await screen.findByRole('dialog')
const title = dialog.querySelector('[data-slot="drawer-title"]')
expect(title?.textContent).toBe('Editar municípios')
expect(dialog.querySelector('[data-slot="drawer-description"]')?.textContent).toBe(
'Maria Souza',
)
await waitFor(() => expect(document.activeElement).toBe(title))
expect(dialog.textContent).toContain('Fechar')
})
})

View file

@ -0,0 +1,95 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
type ToastAction = { label: string; onClick: () => void }
const toastActions: ToastAction[] = []
vi.mock('sonner', () => ({
toast: {
success: (_message: string, options?: { action?: ToastAction }) => {
if (options?.action) toastActions.push(options.action)
},
error: () => {},
},
}))
/**
* Two municípios that are the WHOLE of their território, so the chip collapses
* and removing it is a multi-id delta — which is what raises the undo toast.
*/
const INDEX: MunicipalityPortfolioIndexEntry[] = [
{ id: 1, name: 'Feira de Santana', slug: 'feira-de-santana', region: 'Portal do Sertão' },
{ id: 2, name: 'Serrinha', slug: 'serrinha', region: 'Portal do Sertão' },
{ id: 3, name: 'Ilhéus', slug: 'ilheus', region: 'Litoral Sul' },
// Never assigned, and load-bearing: without a second entry in its território,
// Ilhéus alone would BE Litoral Sul and collapse into a território chip.
{ id: 4, name: 'Itabuna', slug: 'itabuna', region: 'Litoral Sul' },
]
const cell = (municipalityIds: number[]) =>
createElement(MunicipalityPortfolioCell, {
ownerId: 7,
ownerName: 'Maria Souza',
municipalityIds,
municipalityIndex: INDEX,
commitAction: async () => ({ status: 'success' as const }),
drawerTitle: 'Editar municípios',
updateErrorMessage: 'Não foi possível atualizar os municípios.',
})
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
})
afterEach(() => {
toastActions.length = 0
cleanup()
})
afterAll(() => vi.unstubAllGlobals())
describe('município portfolio cell — optimistic baseline', () => {
/**
* Regression pin for a bug that has now been written twice: the delta itself
* was already applied functionally, but the fallback the updater takes when
* the optimistic value is null read the `municipalityIds` of the render that
* created the closure. The toast's "Desfazer" is the one caller that outlives
* its render, so pressing it after a later edit rebuilt the row from a set
* that predated that edit — and because the reconcile effect only clears on an
* exact match, the row stayed wrong through every later revalidation.
*/
it('undoes a removal against the current set, not the one its render captured', async () => {
const { rerender } = render(cell([1, 2]))
// 1. Drop the território (ids 1 and 2) — a multi-id delta, so it toasts.
fireEvent.click(screen.getByRole('button', { name: 'Remover Portal do Sertão — 2 municípios' }))
await waitFor(() => expect(toastActions).toHaveLength(1))
// 2. The write landed and the server re-rendered the row empty.
rerender(cell([]))
// 3. A LATER edit the undo closure knows nothing about.
rerender(cell([3]))
expect(screen.getByText('Ilhéus')).toBeTruthy()
// 4. Undo. It must ADD the território back, never rewind the row to step 1.
await act(async () => {
toastActions[0]?.onClick()
})
await waitFor(() => expect(screen.getByText('Portal do Sertão')).toBeTruthy())
expect(screen.getByText('Ilhéus')).toBeTruthy()
})
})