Record the B37 closing triage on B34+ instead of a new ID.
The /simplify triage produced two live debts, and both are about the same pair of functions the B34+ plan already names in F3, so they landed as F10 of that plan rather than as an escala-dry-pos-b37: the município-side batch actions write one município at a time (findByID + update each — ~870 round trips at the 435 ceiling B37 itself raised, but ~52 at the largest real TI, and no test above a batch of 2), and the two are twins at 2 call sites, one short of the bar. The common trigger is a "select all"/multi-TI affordance or a third batch on that side, and F10 records why the bulk-SQL rewrite is not obviously the answer: it would hand back the scope check that overrideAccess: false gives for free. This delivery also closed B34+ phases outright, which is worth reading in the plan rather than inferring from a diff. F2 is done and its prediction was wrong in a way worth keeping: it stated "o B37 não retira este débito", and B37 read the same evidence the other way — the ~165 measured lines were not deduplicated into useChipRowOverflow, they were deleted when both cells became wrappers of one engine. F9 is done via scopedPortfolioIndex. Half of F3 is done (the three single-id deltas), and the half that stayed out — the set-with-floor form — stayed out because seeing the collapsed code confirmed the plan's own inverted recommendation. F5 and two F6 bullets shrank for the same reason: "written twice across the two surfaces" stopped having two surfaces. Also corrects a comment I got wrong in the previous commit: the batch's sort does not carry the deadlock-avoidance guarantee, since acquireTextAdvisoryLocks sorts its own keys (C8 batched them into one round trip); it only makes the apply order stable.
This commit is contained in:
parent
0a63621d4b
commit
c68e1d2394
6 changed files with 53 additions and 17 deletions
|
|
@ -234,4 +234,4 @@ alwaysApply: false
|
|||
- **A9 / B6 / B9 / B7 / B10 / B11 / B8 F1 / filtros-auto entregues (2026-07-21; em produção desde 2026-07-23):** ver bullets no topo do Status. Próximos abertos: ver [`docs/roadmap.md`](../../../docs/roadmap.md) (compile 2026-07-24) — Janela 1: smoke pós-deploy + **R6** (+ **E8** em paralelo); Janela 3: **B8 F2** (polígonos das zonas de Salvador).
|
||||
- **B8 registrado (2026-07-21); F1 entregue (2026-07-21); F2 entregue (2026-07-26):** polígonos das Praças-zona Salvador/Camaçari (Camaçari saiu do escopo na M1). F1: catálogo `municipalityZoneNeighborhoods` + card na Praça. F2: dissolve malha IBGE → TopoJSON no mapa. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).
|
||||
- **B34 entregue (2026-07-27):** a coluna "Municípios" de `/campanha/liderancas` virou chips editáveis — link para o município, chip de território com contagem quando a liderança cobre o TI inteiro, "×" no canto do chip (hover/foco, sem mexer no tamanho) e busca no fim da linha. **Decisão de produto que vale para toda a vertical:** o que decide inline vs. Drawer é a mídia `pointer:`, não a largura do viewport — `pointer-fine` recebe o editor sempre montado, `pointer-coarse` abre um Drawer com alvos de 44 px, porque um polegar rolando a tabela não pode ser convidado a distinguir "abrir" de "apagar". O anti-goal "input sempre montado em toda linha" (`PRODUCT.md` princípio 3 + `campanha-edit-where-you-see.mdc`) foi **emendado** para relações por chips em ponteiro fino, e não reabre B28/B31/B32: nada aqui é modo de tabela (o toggle de `AdvisorsTable` virou "Editar nome e contato"). Engenharia: `AdvisorMunicipalityCell` foi promovido a `shared/MunicipalityPortfolioCell` (o `ResizeObserver`/"Ver mais…" que o plano chamava de peso morto virou requisito quando a célula passou a estar sempre visível) e Assessores migrou no mesmo PR; uma única action de lote sobrevive (a gêmea de assessor foi deletada, int tests migrados); `LeadershipRowViewModel.municipalityNames` → `municipalityIDs`. Corrigiu de passagem um bug latente do seam `description` de `CampaignTableHead` (**B22**): os helpers do tooltip moravam num módulo `'use client'`, então qualquer chamador RSC quebrava. Débitos: `LeadershipStateDeputyRelationCell` (B31) seguiu com Popover próprio até o **B37 ✓** generalizar os dois num motor comum. Plano: [`docs/plans/chips-municipios-lista-liderancas.md`](../../../docs/plans/chips-municipios-lista-liderancas.md).
|
||||
- **B37 entregue (2026-07-28):** a coluna "Municípios" de `/campanha/dobradinhas` ganhou a mesma peça do B34 (`shared/MunicipalityPortfolioCell`, sem cópia, sem provider novo), escrevendo do lado invertido (`municipality.stateDeputies`) por uma única action de lote transacional (`setStateDeputyMunicipalitiesBatch`, lock por município, **tudo-ou-nada** — um município fora do escopo do assessor derruba o lote inteiro em vez de aplicar parcialmente em silêncio), sem piso mínimo e sem `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` já escopa). Cap de `stateDeputiesArraySchema` subiu 20 → 435 para caber um lote de território inteiro do lado do município. **O critique pós-implementação achou dois P1 e a resposta foi generalizar na hora, em vez de adiar um "3º call site" hipotético:** este item **era** o 3º call site — `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31/B36) duplicavam a mesma máquina de interação (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) com contratos incompatíveis — então `src/components/campaign/shared/RelationChipCell.tsx` nasceu como motor único e as duas células viraram wrappers finos (chips/busca/`FormData`/copy por domínio). O segundo P1 — um assessor via e tentava remover chips de município fora do escopo, falhando em silêncio no documento — fechou **escondendo** o chip: `MunicipalityPortfolioCell` agora filtra por `addableIds` os municípios que renderiza, não só os que oferece adicionar. `LeadershipStateDeputyRelationCell` ganhou `ownerName` e sua primeira suíte de testes própria (zero cobertura antes). Sem migration/collection/Consent. Plano: [`docs/plans/chips-municipios-lista-dobradinhas.md`](../../../docs/plans/chips-municipios-lista-dobradinhas.md).
|
||||
- **B37 entregue (2026-07-28):** a coluna "Municípios" de `/campanha/dobradinhas` ganhou a mesma peça do B34 (`shared/MunicipalityPortfolioCell`, sem cópia, sem provider novo), escrevendo do lado invertido (`municipality.stateDeputies`) por uma única action de lote transacional (`setStateDeputyMunicipalitiesBatch`, lock por município, **tudo-ou-nada** — um município fora do escopo do assessor derruba o lote inteiro em vez de aplicar parcialmente em silêncio), sem piso mínimo e sem `assertMunicipalitiesWithinScope` (`canUpdateMunicipality` já escopa). Cap de `stateDeputiesArraySchema` subiu 20 → 435 para caber um lote de território inteiro do lado do município. **O critique pós-implementação achou dois P1 e a resposta foi generalizar na hora, em vez de adiar um "3º call site" hipotético:** este item **era** o 3º call site — `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31/B36) duplicavam a mesma máquina de interação (delta otimista, undo, combobox ARIA, piso/teto, split pointer-fine/coarse) com contratos incompatíveis — então `src/components/campaign/shared/RelationChipCell.tsx` nasceu como motor único e as duas células viraram wrappers finos (chips/busca/`FormData`/copy por domínio). O segundo P1 — um assessor via e tentava remover chips de município fora do escopo, falhando em silêncio no documento — fechou **escondendo** o chip: `MunicipalityPortfolioCell` agora filtra por `addableIds` os municípios que renderiza, não só os que oferece adicionar. `LeadershipStateDeputyRelationCell` ganhou `ownerName` e sua primeira suíte de testes própria (zero cobertura antes). **O `/simplify` do fechamento achou um bug latente, e ele é a razão de uma convenção daqui ser carga estrutural: `mapCampaignFormActionError` casa a mensagem lançada contra `safeMessages` por string exata**, então uma mensagem de teto escrita como literal cru no `throw` **e** em dois allowlists independentes são três chances de reescrever uma e colapsar em silêncio uma recusa real em "tente novamente". O que as expôs: o helper de delta deste item era a **terceira** cópia byte-a-byte do mesmo cálculo de um id, diferindo só no cap que lê — virou `nextIdsAfterMembership` (cap como dado, três wrappers de uma linha) e os três specs clonados viraram um `describe.each` **com mais cobertura que antes**; as mensagens viraram constantes ao lado dos seus caps (`LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, `MUNICIPALITY_ADVISORS_CAP_MESSAGE`, `STATE_DEPUTY_STAFF_MESSAGE`). A forma "conjunto com piso e teto" ficou fora de propósito: o B34+ já havia invertido essa recomendação numa segunda leitura, e o código colapsado confirmou (genérico das duas formas pede quatro callbacks para poupar ~20 linhas; o helper novo tem 12 sem parâmetro opcional). Duas lacunas de memoização fechadas, ambas restaurando invariante que a base já tinha: `scopedPortfolioIndex` (o `.filter()` do escopo do assessor devolvia array novo por linha, derrotando o WeakMap que existe para compartilhar derivações entre linhas — fecha o F9 do B34+) e a busca da célula de dobradinhas, que renormalizava o catálogo a cada tecla. `maxItems` "por paridade" foi **rejeitado** (dobradinha não tem teto de produto; um "30 generoso" recusaria dobradinha estadual legítima). Dois débitos **absorvidos no B34+ como F10**, sem abrir ID: o laço município-a-município das duas actions de lote e a gemelaridade delas (2 call sites) — gatilho: "selecionar tudo"/multi-TI ou um terceiro lote desse lado. Sem migration/collection/Consent. Plano: [`docs/plans/chips-municipios-lista-dobradinhas.md`](../../../docs/plans/chips-municipios-lista-dobradinhas.md).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -27,6 +27,18 @@ Responsável: —
|
|||
|
||||
**Gate:** `tsc`/`lint`/`format:check`/`check:cycles` limpos; unit 651/651, int 452/452 (suítes completas, não só os arquivos tocados); Aikido 0 findings nos arquivos de primeira parte editados; `knip` bloqueado pela limitação pré-existente e já ledgerada de não conseguir carregar `payload.config.ts` (P3 em `TECH-DEBT.md`) — não uma regressão desta entrega.
|
||||
|
||||
### `/simplify` do fechamento (2026-07-28) — o que mudou depois do as-built acima
|
||||
|
||||
**O achado que vale guardar é um bug latente, não uma dedução:** `nextStateDeputyIdsAfterMunicipalityMembership` nasceu aqui como a **terceira** cópia byte-a-byte do mesmo delta de um id (as três diferiam só no teto que leem), então o algoritmo passou a viver uma vez em `src/lib/relationMembershipDelta.ts` e os três nomes de domínio são wrappers de uma linha. O efeito colateral é que duas das mensagens de teto eram **literais crus repetidos em três lugares de `src` cada** — a lançada mais dois allowlists independentes — e `mapCampaignFormActionError` casa a mensagem lançada contra `safeMessages` por **string exata**: reescrever uma delas colapsaria silenciosamente uma recusa real em "tente novamente". Viraram constantes ao lado dos seus caps, seguindo o `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` que esta entrega já havia ajustado nos mesmos arquivos. A mensagem de staff da dobradinha tinha a mesma exposição em quatro lugares (e `actions/stateDeputy.ts` já **importava** a constante enquanto reescrevia o literal uma linha abaixo), então é `STATE_DEPUTY_STAFF_MESSAGE` — renomeada, porque guarda create/update também, não só o lote. Os três specs clonados viraram um `describe.each`, e a cobertura subiu: cada relação recebeu os casos "pode ir a zero" e "não muta a entrada" que só o clone deste item tinha. Unit 651 → **715**.
|
||||
|
||||
Na action de lote: o slug da dobradinha deixou de ser resolvido antes do laço (ele existe só para mirar o `revalidate`, que o chamador já pula quando nada mudou — o guard que o gêmeo `setLeadershipMunicipalitiesMembershipRecord` já tinha), o segundo dedup redundante saiu (o `.transform` do schema já deduplica; o `sort` fica, e o comentário dele foi corrigido: quem garante ordem de lock é `acquireTextAdvisoryLocks`, que ordena as próprias chaves), e o `435` cru que este item passou a dividir com o lote de assessores é `MAX_MUNICIPALITIES_PER_BATCH`.
|
||||
|
||||
Duas lacunas de memoização fechadas, ambas restaurando invariantes que o código já tinha em outro lugar: o `.filter()` do escopo do assessor devolvia um array novo **por linha**, justamente derrotando o WeakMap que existe para compartilhar derivações entre linhas (`scopedPortfolioIndex` memoiza por `(index, addableIds)`, fechando o **F9** do B34+), e a busca do `LeadershipStateDeputyRelationCell` chamava `matchesAtWordStart` por candidato, pagando um passe NFD e três regexes Unicode dos dois lados a cada tecla sobre o catálogo de lideranças — o custo que o próprio docstring de `wordStartFilter` avisa; agora normaliza a query uma vez contra rótulos cacheados, como `municipalityPortfolio` já fazia.
|
||||
|
||||
**Rejeitado com motivo:** `maxItems` na célula de dobradinhas, sugerido "por paridade" com o B34. Uma dobradinha não tem teto de produto de municípios (é por isso que o bound é o catálogo), então um "30 generoso" inventaria uma regra que recusa uma dobradinha estadual legítima, e o 435 honesto é inalcançável quando os ids são únicos.
|
||||
|
||||
**Adiado com gatilho, absorvido no B34+ como fase F10** (não abriu ID novo): o laço município-a-município das duas actions de lote do lado do município (`findByID` + `update` por município — ~870 round trips no teto de 435, mas ~52 no maior TI real, e nenhum teste acima de lote 2) **e** a gemelaridade das duas actions (2 call sites, abaixo do limiar de 3). Gatilho comum: uma afordância de "selecionar tudo"/multi-TI, ou um terceiro lote do lado do município.
|
||||
|
||||
## ⚠️ Conflito com o pedido literal — leia antes de implementar
|
||||
|
||||
O pedido de produto pede um **botão de "modo de edição"** para a coluna inteira, espelhando o toggle "Editar" de `/campanha/assessores` (`AdvisorsTable`). No mesmo dia (2026-07-25), essa **mesma classe de pedido** já foi resolvida quatro vezes na direção oposta, em três tabelas diferentes: **B28**/**B31**/**B32** (lista de lideranças) e **B34** (a mesma pergunta, para a coluna "Municípios" de lideranças). O argumento se repete porque a estrutura se repete: o toggle de `AdvisorsTable` existe porque **todas** as colunas daquela tela são editáveis (nome/e-mail/celular/carteira); em `/campanha/dobradinhas`, só "Municípios" seria editável — "Nome", "Partido" e "Lideranças" continuam leitura. Este plano **segue o precedente travado** (Popover disparado pela célula, não modo de tabela) em vez do texto literal do pedido. Todas as demais funcionalidades pedidas — chips, link para o município, adicionar por digitação, remover, lote de território/ZE — estão contempladas; só o **container** muda. _(proposto — validar com produto; se a mesa realmente quiser o toggle de tabela aqui, é a exceção que reabre B28/B31/B32/B34 também — mudar um muda todos, porque o argumento é o mesmo.)_
|
||||
|
|
|
|||
|
|
@ -54,11 +54,21 @@ Contra a escada de cache do repo: o degrau 1 (`cache()`) rende **zero** — é u
|
|||
|
||||
**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
|
||||
## 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.
|
||||
**Fechado no `/simplify` do B37 ✓ (2026-07-28), pela segunda saída — e ela não ficou dentro da célula.** `searchIndex` filtrava `municipalityIndex` quando há `addableIds`, produzindo **um array novo por linha**, e era esse array que virava a chave do WeakMap de derivações: 25 conjuntos (4 Maps cada) em vez de um, mais o build O(435) repetido na primeira busca de cada linha (**1,89 ms** para as 25 contra 0,076 ms para uma).
|
||||
|
||||
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).
|
||||
O filtro passou a ser `scopedPortfolioIndex(index, addableIds)` em `src/lib/municipalityPortfolio.ts`, memoizado num WeakMap aninhado por `(index, addableIds)` — as duas chaves são estáveis por request (o índice é montado uma vez pelo RSC; o `Set` de escopo também), então todas as linhas voltam a receber **o mesmo array** e a memoização de derivações volta a funcionar como escrita. Mora na lib, não na célula, porque a alternativa "WeakMap dentro do componente" seria uma segunda camada de cache ao lado da que já existe no mesmo módulo — e porque a primeira saída (`addableIds` como opção de `searchMunicipalityPortfolio`) muda a assinatura de uma função compartilhada para resolver um problema de identidade de array, que é o que um WeakMap já resolve.
|
||||
|
||||
## F10 — O lote do lado do município escreve município por município (absorvido do B37 ✓)
|
||||
|
||||
Entrou por triage do `/simplify` do **B37 ✓** (2026-07-28), como fase desta família e não como item novo, porque a função que a fase discute — `setAdvisorMunicipalitiesBatchRecord` — já é nomeada no F3 acima.
|
||||
|
||||
`setStateDeputyMunicipalitiesBatchRecord` e `setAdvisorMunicipalitiesBatchRecord` percorrem a relação **invertida** (o array mora em `municipality.advisors`/`municipality.stateDeputies`), e cada município do lote custa **um `findByID` + um `update`** com `overrideAccess: false` — a leitura existe porque o acesso por linha e por campo do Payload é o que escopa o assessor, e o update precisa do array inteiro. Os locks **não** são o problema (o C8 ✓ já os bateu em 1 round trip via `unnest`); o laço é. No teto do schema (435, elevado pelo próprio B37 para caber um TI inteiro) são ~870 round trips numa transação, cada um pagando ~1–3 ms de `gru1`↔`sa-east-1` — e o int spec mais gordo do caminho usa lote de 2, então o comportamento acima de um punhado de municípios é **inferido, não medido**.
|
||||
|
||||
Não foi corrigido no fechamento porque o teto é inalcançável pelo produto de hoje: o maior lote que a UI oferece é um chip de território, e o maior TI da Bahia tem **26** municípios (Litoral Sul — medido em `bahiaTerritories.ts`, ~52 round trips). **O gatilho é uma afordância de "selecionar tudo" / "aplicar a vários TIs"** — quem construir isso mede antes: ou reescreve o laço em SQL bulk sobre a transação do Payload (precedente `supporterImportBulk.ts`, que já faz `payload.db.drizzle` em chunks dentro da transação aberta), **ou** mantém o laço e trava o lote num limite honesto abaixo do universo. Reescrever em bulk custa a checagem de escopo que o `overrideAccess: false` dá de graça hoje — ela teria de ser reimplementada explicitamente contra `getAdvisorMunicipalityIds`, que é exatamente o tipo de reimplementação de access que o repo evita, então a medição vem antes da escolha.
|
||||
|
||||
**Segunda metade, com o mesmo gatilho: as duas actions são gêmeas (2 call sites).** Fora o nome do campo, do lock e da mensagem, são a mesma sequência: parse, dedup+sort, transação, `reloadStaffActor`, locks em bloco, laço de leitura escopada + delta puro + update, coleta de slugs mudados, revalidate por delta. Está **abaixo** do limiar de 3 do `AGENTS.md`, e — diferente do F3, onde o algoritmo era idêntico — aqui o que se repete é uma sequência de orquestração cuja extração pediria parametrizar "de que lado da aresta eu escrevo", a fontanaria genérica que o F3 já rejeitou uma vez. Registrado para que um **terceiro** lote do lado do município não nasça como terceira cópia: quem o escrever extrai `withMunicipalitySideRelationBatch({ relation, lockPrefix, computeNext })` primeiro, e fecha esta metade junto com a de cima.
|
||||
|
||||
## F1 — Regressão de First Load JS nas duas listas (prioritário)
|
||||
|
||||
|
|
@ -84,9 +94,13 @@ O staff edita muito e navega pouco, o que favorece **(b)** — o bundle é pago
|
|||
|
||||
Um terceiro lance, **ortogonal e cumulativo**: carregar o `Drawer` por `next/dynamic` atrás do primeiro toque. Ele só abre em `pointer-coarse`, e uma gaveta que já anima pode absorver o import. Recupera parte dos +42 kB que atingem as duas rotas.
|
||||
|
||||
## F2 — `useChipRowOverflow`: o gatilho disparou e a máquina foi copiada
|
||||
## F2 ✓ — `useChipRowOverflow`: o gatilho disparou e a máquina foi copiada
|
||||
|
||||
`MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓) carregam ~105 linhas idênticas de medição de overflow — as constantes, o guard de "não trate o primeiro callback do observer como resize", a invalidação por conteúdo, o filtro `lastVisibleTop + 1`, o laço de largura da cauda, o `expandToggle` com `tabIndex={measuring ? -1 : undefined}` — incluindo um comentário verbatim. As duas células são renderizadas **pela mesma tabela**.
|
||||
**Fechado no B37 ✓ (2026-07-28), e a previsão desta fase estava errada — vale registrar por quê.** Estava escrito aqui que "**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`". O critique pós-implementação do B37 leu a mesma evidência ao contrário: se as duas células rodam na mesma tabela com a mesma máquina escrita duas vezes e contratos incompatíveis, então **o B37 já era o 3º call site** — e a resposta foi generalizar na hora em vez de registrar um quarto adiamento. `src/components/campaign/shared/RelationChipCell.tsx` nasceu como o motor único e as duas células viraram wrappers finos (`MunicipalityPortfolioCell` 816 → ~180 linhas; `LeadershipStateDeputyRelationCell` 450 → ~185), então as ~165 linhas que esta fase mediu **não foram deduplicadas num hook, foram deletadas** — não há mais duas cópias de medição, adoção por `sameIdSet`, `expandToggle` nem redutor otimista para extrair. O módulo do qual a cópia sobreviveu continua existindo, mas sem máquina dentro: o que resta nele é domínio (chips com sufixo de partido, `FormData`, copy).
|
||||
|
||||
Não reabrir como "extrair `useChipRowOverflow`": a medição vive uma vez, dentro do motor, e um hook separado só faria sentido para um terceiro consumidor que **não** seja uma célula de chips.
|
||||
|
||||
**Descrição original (histórico):** `MunicipalityPortfolioCell` e `LeadershipStateDeputyRelationCell` (B31 ✓) carregavam ~105 linhas idênticas de medição de overflow — as constantes, o guard de "não trate o primeiro callback do observer como resize", a invalidação por conteúdo, o filtro `lastVisibleTop + 1`, o laço de largura da cauda, o `expandToggle` com `tabIndex={measuring ? -1 : undefined}` — incluindo um comentário verbatim. As duas células são renderizadas **pela mesma tabela**.
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -94,9 +108,17 @@ A duplicação é **anterior** ao B34 (nasceu entre `AdvisorMunicipalityCell` e
|
|||
|
||||
**O `/simplify` do B32+ ✓ mexeu nesta célula e mudou duas coisas para esta fase (2026-07-28).** A região `aria-live` dela estava **dentro** do `PopoverContent` — o bug exato que o B32+ F4 corrigiu nos outros cinco controles, e que aqui deixava a falha e o "Salvando…" mudos justamente depois de fechar, que é quando a gravação acontece — e foi içada para fora do `<Popover>`, com `role="status"`, já nesta forma: **a migração não pode reintroduzi-la dentro do overlay.** E o argumento de custo virou: `CampaignCellEditOverlay` já é usado por seis chamadores, então a parte "trigger + Popover" desta célula deixou de ser trabalho de extração e passou a ser adoção de casca pronta (inclusive o `initialFocus` e o `aria-busy` que ela não tem) — o que sobra de verdade nesta fase é a máquina de chips e o redutor otimista. Um detalhe da adoção, medido no B34: a folha do container é um `Drawer` sempre montado e reporta `onOpenChange(false)` quando **qualquer** dismiss varre a pilha de camadas, não só quando ela fecha.
|
||||
|
||||
## F3 — Um só delta de membership
|
||||
## F3 — Um só delta de membership (metade fechada)
|
||||
|
||||
`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.
|
||||
**A metade dos três deltas de um id foi fechada no `/simplify` do B37 ✓ (2026-07-28), e a recomendação invertida da segunda leitura se confirmou.** `nextStateDeputyIdsAfterMunicipalityMembership` (nascido no B37) era a **terceira** cópia byte-a-byte do mesmo algoritmo de um id — os três diferiam só no teto que leem —, então ele vive uma vez em `src/lib/relationMembershipDelta.ts` (`nextIdsAfterMembership(current, id, assigned, { max, message })`) e os três nomes de domínio são wrappers de uma linha ligando o seu próprio cap. Os três specs clonados viraram um `describe.each`, e a cobertura **subiu**: cada relação agora recebe os casos "pode ir a zero" e "não muta a entrada" que só o clone do B37 tinha, mais a asserção explícita de que cada wrapper recusa no **seu** teto com a **sua** mensagem.
|
||||
|
||||
**`nextMunicipalityIdsAfterLeadershipMembership` ficou de fora de propósito** — é a forma "um conjunto com piso e teto", e a segunda leitura registrada acima (um genérico cobrindo as duas formas precisaria de quatro callbacks para poupar ~20 linhas) continua valendo depois de ver o código colapsado: o helper novo tem 12 linhas e nenhum parâmetro opcional.
|
||||
|
||||
**Efeito colateral que era um bug latente, e a razão de o pareamento importar:** duas das mensagens de teto eram literais crus repetidos em três lugares de `src` cada (lançada + dois allowlists independentes), e `mapCampaignFormActionError` casa mensagem lançada contra `safeMessages` por **string exata** — reescrever uma delas colapsaria silenciosamente uma recusa real em "tente novamente". Viraram constantes ao lado dos seus caps (`LEADERSHIP_STATE_DEPUTIES_CAP_MESSAGE`, `MUNICIPALITY_ADVISORS_CAP_MESSAGE`), seguindo o precedente `MUNICIPALITY_STATE_DEPUTIES_CAP_MESSAGE` que já existia nos mesmos arquivos. A mensagem de staff da dobradinha tinha a mesma exposição em quatro lugares — e `actions/stateDeputy.ts` já **importava** a constante enquanto reescrevia o literal uma linha abaixo — então é `STATE_DEPUTY_STAFF_MESSAGE` (renomeada: guarda create/update também, não só o lote), e a de conflito que ela divide com a rota de criação também tem nome.
|
||||
|
||||
**Continua aberto nesta fase:** o `withLeadershipRelationDelta` abaixo (política de escrita por delta sob lock consultivo numa linha de liderança).
|
||||
|
||||
**Descrição original (histórico):** `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.
|
||||
|
||||
No mesmo arquivo, `setLeadershipMunicipalitiesMembershipRecord` e `setLeadershipStateDeputyMembershipRecord` são ~55 de ~90 linhas iguais e **adjacentes**, com três comentários copiados palavra por palavra. Um `withLeadershipRelationDelta({ leadershipId, relation, lockKey, computeNext })` — local ao arquivo, não genérico entre collections — dono da transação, do lock, da leitura escopada e do early return de no-op, deixando ao chamador a asserção de escopo e a busca de slug. Precedente declarado: `runStaffEntityMutation` (política no helper, mutação tipada no chamador). São 2 call sites, abaixo do limiar de 3 — mas "escrita por delta sob lock consultivo numa linha de liderança" é uma política que merece nome, que é a outra metade da mesma regra.
|
||||
|
||||
|
|
@ -106,14 +128,15 @@ No mesmo arquivo, `setLeadershipMunicipalitiesMembershipRecord` e `setLeadership
|
|||
|
||||
`leadershipData.ts` estreitou certo (`municipalityIDs: number[]`), mas `AdvisorsTable` faz `row.municipalities.map((m) => m.id)` e joga `name`/`slug` fora — enquanto `municipalitiesByAdvisorIds` ainda roda um `payload.find` inteiro sobre `municipality` para produzi-los. A mesma decisão tomada de dois jeitos opostos na mesma entrega. Dar ao loader **de lista** um caminho só-ids (a forma `{id,name,slug}` continua para `assessores/[id]`, que de fato renderiza os nomes). Se o F1 (b) entrar, cai junto: o cliente resolve tudo pelo catálogo.
|
||||
|
||||
## F5 — Combobox à mão onde o kit já embrulha um
|
||||
## F5 — Combobox à mão onde o kit já embrulha um (escopo reduzido pelo B37 ✓)
|
||||
|
||||
São ~90 linhas de teclado/ARIA mais ~20 de atributos duplicados entre as duas superfícies. **A restrição que descartaria a alternativa não se aplica:** o cmdk `Command` está corretamente fora (ele resolve itens consultando `[cmdk-item]` dentro da própria raiz, e o listbox aqui **precisa** ser portalizado porque o container de scroll da tabela recorta uma lista em fluxo) — mas `ui/combobox.tsx`, que embrulha o `Combobox` do base-ui, já aceita **`anchor`** e tem um hook `data-chips={!!anchor}`, ou seja, foi escrito para exatamente esta forma: input dentro de uma linha de chips, listbox portalizado ancorado na célula. Fit parcial (hits heterogêneos — município/TI/ZE — e a recusa por teto pedem render de item próprio de qualquer jeito), então remove a metade de teclado/ARIA, não o todo.
|
||||
**A metade "duplicada" morreu com o `RelationChipCell` (B37 ✓, 2026-07-28):** os ~20 atributos repetidos entre as duas superfícies e as ~16 linhas de `searchInputProps` do F6 não existem mais — o input de busca e o listbox são escritos **uma vez**, no motor. O que resta são as ~90 linhas de teclado/ARIA num único lugar, o que **melhora** a economia da troca em vez de piorá-la: adotar `ui/combobox.tsx` passou a ser uma edição, não duas mantidas em sincronia. O argumento abaixo (fit parcial, `anchor` já previsto, cmdk corretamente fora) continua íntegro.
|
||||
|
||||
**Descrição original (histórico):** eram ~90 linhas de teclado/ARIA mais ~20 de atributos duplicados entre as duas superfícies. **A restrição que descartaria a alternativa não se aplica:** o cmdk `Command` está corretamente fora (ele resolve itens consultando `[cmdk-item]` dentro da própria raiz, e o listbox aqui **precisa** ser portalizado porque o container de scroll da tabela recorta uma lista em fluxo) — mas `ui/combobox.tsx`, que embrulha o `Combobox` do base-ui, já aceita **`anchor`** e tem um hook `data-chips={!!anchor}`, ou seja, foi escrito para exatamente esta forma: input dentro de uma linha de chips, listbox portalizado ancorado na célula. Fit parcial (hits heterogêneos — município/TI/ZE — e a recusa por teto pedem render de item próprio de qualquer jeito), então remove a metade de teclado/ARIA, não o todo.
|
||||
|
||||
## 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.
|
||||
- ~~**`PortfolioChipLabel`**~~ ✓ e ~~**`searchInputProps(surface)`**~~ ✓ — os dois eram "escrito duas vezes nas duas superfícies", e as duas superfícies passaram a ser **uma** com o `RelationChipCell` (B37 ✓): o rótulo do chip é um `renderChipLabel` do motor e o input de busca é montado uma vez. Nada a extrair.
|
||||
- **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
|
|
@ -106,8 +106,9 @@ export const setStateDeputyMunicipalitiesBatchRecord = async (
|
|||
) => {
|
||||
const { stateDeputyId, municipalityIds, assigned } =
|
||||
stateDeputyMunicipalitiesBatchSchema.parse(input)
|
||||
// Already deduped by the schema's `.transform`; sorted only so the advisory
|
||||
// locks below are always taken in the same order (deadlock avoidance).
|
||||
// Already deduped by the schema's `.transform`; sorted only so a batch applies
|
||||
// and reports in a stable order — `acquireTextAdvisoryLocks` sorts its own keys,
|
||||
// so the deadlock-avoidance ordering does not depend on this line.
|
||||
const uniqueMunicipalityIds = [...municipalityIds].sort((left, right) => left - right)
|
||||
|
||||
return withPayloadTransaction(
|
||||
|
|
|
|||
Loading…
Reference in a new issue