Register the E14 closing triage on B32+.

Three debts, no new ID. The fifth JSON control and route arrived without
touching F1 — the level movement is an explicit submit, not a debounce — which
is exactly the escape clause F2/F3 were parked behind, so they stop being
conditional. F2 also changes character: the same-origin check that
`isSameOriginRequest` documents itself as owning is copied into five routes, so
the one that forgets the line fails open. That is not boilerplate and is not
cuttable by appetite.

F5 is B42's own deferred trigger firing: it parked the footer slot on "a second
control needing explicit submit in a Drawer", and this is that control. What the
deferral could not know is that the shell pins "Fechar" while "Registrar
movimento" sits below the fold, so the action that discards a typed rationale is
the reachable one.

Discarded with reasons in the triage: `new Date()` during render (the server
re-runs the rules and a 409 is a designed state), and `levelNote` in the 435-row
select, already written down in E14's own plan next to `politicalTrend.note`,
which has the same shape and must be fixed with it.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-27 22:24:34 -03:00
parent adb1773be8
commit 93b58a9b3c
3 changed files with 20 additions and 12 deletions

View file

@ -3,8 +3,8 @@
Status: rascunho
Atualizado em: 2026-07-27
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B32+**, fill-in de engenharia)
Impeccable: A — N/A (sem superfície UI nova; extrai comportamento já entregue sem mudar contrato de rede nem markup visível)
Appetite: ~1–1,25 dia eng; F1 é uma auditoria com decisão binária (extrair ou registrar rejeição medida, mesmo formato do B33+); F2/F3 são baratas e só valem a pena se F1 concluir "extrair"
Impeccable: A para F1/F2/F3 (sem superfície UI nova; extrai comportamento já entregue sem mudar contrato de rede nem markup visível); **B para F4/F5**, que mudam o que o usuário ouve e o que ele consegue tocar na casca compartilhada
Appetite: ~1,25–1,5 dia eng; F1 é uma auditoria com decisão binária (extrair ou registrar rejeição medida, mesmo formato do B33+); F2/F3 deixaram de ser condicionais quando o 5º call site chegou (E14 ✓); F4/F5 são fases da casca, independentes do resultado de F1
Responsável: —
## Dados → decisão → apresentação
@ -33,16 +33,19 @@ A comparação linha a linha dos três (feita nesta triage, `capture-review-debt
**Atualização (2026-07-27, pós-B42 ✓):** o **container** dos três já saiu — `shared/CampaignCellEditOverlay.tsx` (Popover `md+` / Drawer mobile) absorveu Popover/Drawer/trigger/tooltip e `LeadershipListSupportStatusControl` foi migrado como 4º call site. Isso **não** consome o F1 (a casca não toca estado, refs, debounce nem `fetch` — foi o corte deliberado do B42, "só o container"), mas muda duas coisas para a auditoria: os três controles ficaram ~25–40 linhas menores, então a máquina de auto-save é agora a **maior** coisa duplicada neles; e o `/simplify` do B42 acrescentou à lista de peças idênticas um **13º item**, `reportFailure` — `setErrorMessage` + `toast.error` **só quando o overlay já fechou**, porque fechar desmonta o `Alert`/`aria-live` que carregariam o erro. Ele nasceu copiado nos quatro controles (os três do debounce + assessores) no mesmo passe, o que o torna a peça mais recente e mais óbvia do hook, se F1 concluir "extrair".
**Atualização (2026-07-27, pós-E14 ✓ — a triage do fechamento do E14):** o **gatilho de 5º call site** que a "Questão em aberto" abaixo guardava para F2/F3 **disparou**. `MunicipalityListLevelControl` (movimento de nível de envolvimento) e `POST /campanha/municipios/engagement-level` são o quinto controle e a quinta rota da família, e chegaram sem tocar em F1: o controle é de **submit explícito**, não de auto-save por debounce, então ele não é um 4º corpo da máquina do F1 — mas repete o preâmbulo dos dois lados como qualquer outro. F2 também mudou de natureza nessa contagem: `isSameOriginRequest` documenta-se como dona da política de CSRF, e a política está copiada em cinco `route.ts`, então **a rota que esquecer a linha falha aberta** — é uma verificação de origem que depende de disciplina de cópia, não do tipo. Isso põe F2 no piso de expensive_lock e a tira da condicionalidade a F1. Do lado do cliente, F3 fica igualmente com cinco call sites, e continua valendo só para o transporte (o controle do E14 não tem estado de auto-save para compartilhar).
As diferenças reais, que é o que torna a extração não trivial (achado do `/simplify` reuse review do B32, 2026-07-26): o **shape do valor** diverge (`VoteEstimateScenarioViewModel` objeto com 3 cenários × `MunicipalityListSavedPoliticalTrend` `{status, note}` × `SupportStatus` escalar), **display vs. draft** existe em dois dos três (o B32 colapsou os dois porque só tem um campo — ver "Decisões travadas" do próprio plano B32) e o **B24** tem um guard extra (`inFlightTrendRef`) para não abortar+reenviar o mesmo payload quando blur e fechamento do popover disparam o mesmo flush.
Um quarto controle, `MunicipalityListAdvisorsControl.tsx` (B27, chips + `Command`), **não** compartilha essa máquina — é delta multi-seleção com `requestSeqRef`/`latestConfirmedRef` (fora de escopo, mecânica genuinamente diferente) — mas compartilha com os quatro (incluindo ele) o **preâmbulo do `fetch`** (`credentials: 'same-origin'`, `Content-Type`/`Accept` JSON, `cast` da resposta) e, do lado do servidor, os quatro `route.ts` (`liderancas/support-status`, `municipios/political-trend`, `municipios/expected-votes`, `municipios/advisors`) repetem o mesmo preâmbulo de rota: `isSameOriginRequest(request)` → 403 → `parseCampaignJsonRequestBody` → `bodySchema.parse` → `try/catch` com `campaignJsonMutationErrorResponse`.
Um quarto controle, `MunicipalityListAdvisorsControl.tsx` (B27, chips + `Command`), **não** compartilha essa máquina — é delta multi-seleção com `requestSeqRef`/`latestConfirmedRef` (fora de escopo, mecânica genuinamente diferente) — mas compartilha com os cinco (incluindo ele e o do E14) o **preâmbulo do `fetch`** (`credentials: 'same-origin'`, `Content-Type`/`Accept` JSON, `cast` da resposta) e, do lado do servidor, os cinco `route.ts` (`liderancas/support-status`, `municipios/political-trend`, `municipios/expected-votes`, `municipios/advisors`, `municipios/engagement-level`) repetem o mesmo preâmbulo de rota: `isSameOriginRequest(request)` → 403 → `parseCampaignJsonRequestBody` → `bodySchema.parse` → `try/catch` com `campaignJsonMutationErrorResponse`.
## Objetivos
- **F1 (dominante):** decidir, com auditoria de código (não de intenção), se um hook `useCampaignListAutosave<TValue>` genérico compensa nos três call sites do debounce — e, se sim, extraí-lo; se não, registrar a rejeição medida (precedente B33+: a auditoria pode derrubar a premissa do plano).
- **F2 (condicional a F1 ter concluído "vale a pena mexer aqui"):** avaliar um helper de preâmbulo de rota (`guardCampaignJsonMutationRequest` ou nome equivalente) que combine `isSameOriginRequest` + `parseCampaignJsonRequestBody`, reduzindo o boilerplate dos 4 `route.ts`.
- **F3 (condicional, menor prioridade que F2):** avaliar um wrapper fino de `fetch` cliente (`postCampaignJson<TResponse>`) para os 4 controles, cobrindo só o preâmbulo comum (`credentials`/headers/`cast`), não a máquina de estado.
- **F2 (não é mais condicional; gatilho de 5º call site disparado pelo E14 ✓):** extrair o helper de preâmbulo de rota (`guardCampaignJsonMutationRequest` ou nome equivalente) que combina `isSameOriginRequest` + `parseCampaignJsonRequestBody` nos 5 `route.ts`. A motivação subiu de boilerplate para **política**: a verificação de origem só vale se toda rota da família a escrever, e hoje escrevê-la é opcional por construção — a que esquecer aceita POST cross-origin em silêncio. Fase mais importante do lote junto com F5, e a primeira a rodar.
- **F3 (não é mais condicional; mesmo gatilho, menor prioridade que F2):** wrapper fino de `fetch` cliente (`postCampaignJson<TResponse>`) para os 5 controles, cobrindo só o preâmbulo comum (`credentials`/headers/`cast`), nunca a máquina de estado.
- **F4 (a11y, entrou pela triage do B42 ✓ em 2026-07-27; independente de F1 "extrair ou não"):** a região viva de cada controle é um `<p className="sr-only" aria-live="polite">` **dentro** do overlay, e fechar é justamente o que faz o flush da gravação — então "Salvando…" é anunciado numa região que acabou de desmontar, e o leitor de tela não recebe nada. É o mesmo furo que o `toast.error` do B42 tapou para o **erro**, mas o sucesso e o "salvando" continuam mudos. A casca (`CampaignCellEditOverlay`) é o lugar natural de uma região que sobrevive à superfície — o que muda o scaffold, e é por isso que é fase deste plano e não do B42. **Guardrail:** uma região por controle, não uma global por página; `escala-dry-pos-b33.md` já corrigiu uma página com **três** regiões vivas competindo, e a cura não pode reintroduzir isso.
- **F5 (slot `footer` + `formId` na casca; gatilho registrado pelo B42 ✓ e disparado pela triage do E14 ✓ em 2026-07-27; independente de F1):** o B42 adiou o slot com o gatilho "um 2º controle precisar de submit explícito num Drawer". `MunicipalityListLevelControl` é esse controle, e a falta do slot é pior do que o adiado previa — não é só o Sinal (B26) que fica sem migrar. Na folha, `CampaignCellEditOverlay` fixa um `DrawerFooter` com **"Fechar"**, enquanto "Registrar movimento" fica no fim de um corpo rolável com select, duas textareas e até dois checkboxes: **a ação que descarta está pinada e a que constrói está abaixo da dobra**, e "Fechar" joga fora uma justificativa digitada sem perguntar. O precedente de forma é o `MunicipalityListSignalControl`, que mantém container próprio justamente para pôr o submit no `DrawerFooter` com "Cancelar" ao lado. Escopo: slot opcional `footer` (mais `formId`, para o submit viver fora do `<form>` por HTML padrão), o controle do E14 e o Sinal como call sites, e um caso em `campaignCellEditOverlay.unit.spec.ts` pinando que a folha com `footer` **não** renderiza o "Fechar" default. Só o `variant="sheet"` muda: no Popover o submit já é visível sem rolagem.
- Guardrail comum às três fases: **zero mudança de contrato de URL/rede e zero mudança de comportamento visível** — é refactor puro, testável pelos specs/e2e já existentes (`campaignLeaderships.e2e.spec.ts`, `campaignMunicipalities.e2e.spec.ts`).
## Decisões travadas
@ -53,7 +56,7 @@ Um quarto controle, `MunicipalityListAdvisorsControl.tsx` (B27, chips + `Command
## Questões em aberto
- **O hook deve cobrir também o `inFlightTrendRef` do B24 (dedup de blur+close) ou deixar esse guard como responsabilidade do chamador?** **Opções:** (a) o hook expõe um `flush()` idempotente que já resolve isso internamente (um único caminho de save, chamado por blur ou por close); (b) o hook fica "burro" sobre isso e cada chamador decide se precisa do guard. **Recomendação:** (a) — o guard existe porque hoje há _dois_ caminhos de código para chegar ao mesmo save (o `onBlur` do textarea e o `handleOpenChange`); um hook com `flush()` único elimina a duplicidade na raiz em vez de generalizar o sintoma.
- **Vale a pena F2/F3 se F1 concluir "não compensa"?** **Opções:** (a) fazer F2/F3 de qualquer forma, são baratas e independentes de F1; (b) não fazer nenhuma se F1 rejeitar, porque a motivação de fundo (essa família de controles é jovem e ainda pode crescer/divergir) provavelmente também derruba F2/F3. **Recomendação:** (b) com uma ressalva — reavaliar F2/F3 isoladamente **só** se um 5º `route.ts`/controle chegar antes de alguém revisitar este plano (mesma lógica de "3º call site" aplicada à sub-dívida).
- ~~**Vale a pena F2/F3 se F1 concluir "não compensa"?**~~ **Resolvida em 2026-07-27 pela ressalva da própria pergunta.** A recomendação era (b), não fazer nada se F1 rejeitasse, _"com uma ressalva — reavaliar F2/F3 isoladamente só se um 5º `route.ts`/controle chegar antes de alguém revisitar este plano"_. Chegou: o E14 ✓ entregou o quinto de cada um antes de qualquer auditoria de F1. F2/F3 passam a valer por conta própria, e F2 ganhou um motivo que não existia quando a pergunta foi escrita (falha aberta por omissão, ver Contexto).
## Abordagem proposta
@ -66,6 +69,9 @@ flowchart LR
Guard --> R2["municipios/political-trend/route.ts"]
Guard --> R3["municipios/expected-votes/route.ts"]
Guard --> R4["municipios/advisors/route.ts"]
Guard --> R5["municipios/engagement-level/route.ts"]
Shell["CampaignCellEditOverlay<br/>(F4 região viva + F5 slot footer)"] --> LC["MunicipalityListLevelControl"]
Shell --> SC["MunicipalityListSignalControl"]
```
Componentes (condicionais à decisão de cada fase):
@ -92,7 +98,7 @@ Componentes (condicionais à decisão de cada fase):
## Adiado com gatilho
- **F2/F3 se F1 rejeitar a extração do hook principal.** Ver "Questões em aberto" — reavaliar isoladamente só com um 5º call site (rota ou controle).
- ~~**F2/F3 se F1 rejeitar a extração do hook principal.**~~ **Gatilho disparado em 2026-07-27** pelo E14 ✓ (5ª rota e 5º controle). F2/F3 são fases incondicionais deste plano.
- **`MunicipalityListAdvisorsControl` adotar o preâmbulo de fetch do F3** (se F3 for feito) — hoje ele tem uma política de erro diferente (`pendingCountRef`/sequência, sem revert único), então adotar herdaria só o `fetch`+`cast`, não a lógica de estado; barato, mas não é o call site que motivou o item. Gatilho: F3 concluído e aprovado.
- **Convergir o _corpo_ de multi-seleção (chips + `Command`), não só o container.** Contagem as-built, medida na triage do B42 ✓ (2026-07-27), para o próximo leitor não re-derivar: `shared/LeadershipStateDeputyRelationCell.tsx` (B36 ✓, já em `shared/` e já parametrizada por um record de `COPY`, servindo as duas direções da relação) e `MunicipalityListAdvisorsControl.tsx` (B27 ✓) repetem o mesmo corpo **string por string** — `w-80 p-0`, header `relative flex flex-col gap-2 p-3 pb-0`, Spinner, linha de chips `flex flex-wrap gap-1.5 px-3 pt-2`, o `Badge` de remover (`max-w-full cursor-pointer gap-1 pr-1 font-normal hover:bg-destructive/15`), `Command shouldFilter={false}` e a linha vazia `px-3 py-6 text-center`. `AdvisorMunicipalityCell` é uma terceira grafia, mas **inline** (sem overlay), então não conta como 3º call site do mesmo corpo. **Gatilho:** um 3º corpo em overlay (B34/B37 são os candidatos nomeados) — e aí a peça de destino é a que já mora em `shared/`, não uma quarta. Os planos B31/B34 registram este mesmo adiado do lado do widget; esta é a contagem do lado do markup.
@ -104,6 +110,8 @@ Componentes (condicionais à decisão de cada fase):
- [polimento-mobile-lista-municipios.md](polimento-mobile-lista-municipios.md) (B42 ✓) — extraiu o **container** dos mesmos três controles (`CampaignCellEditOverlay`) e introduziu `reportFailure` nos quatro; leia o "As-built" antes de auditar o F1
- [escala-dry-pos-b33.md](escala-dry-pos-b33.md) — precedente direto de forma: hook via callbacks (não módulos de domínio), auditoria derrubando a premissa do plano antes de escrever código
- `src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx`, `MunicipalityListTrendControl.tsx`, `src/components/campaign/leadership/LeadershipListSupportStatusControl.tsx` — os três call sites de F1
- `src/app/(campaign)/campanha/(app)/liderancas/support-status/route.ts`, `municipios/{political-trend,expected-votes,advisors}/route.ts` — os quatro call sites de F2
- `src/app/(campaign)/campanha/(app)/liderancas/support-status/route.ts`, `municipios/{political-trend,expected-votes,advisors,engagement-level}/route.ts` — os cinco call sites de F2
- `src/utilities/campaignJsonMutationRoute.ts`, `src/utilities/sameOriginRequest.ts` — onde F2 pousaria
- AGENTS.md — abstrações precisam de 3+ call sites (aqui há exatamente 3 para F1, 4 para F2/F3)
- [niveis-de-envolvimento.md](niveis-de-envolvimento.md) (E14 ✓) — o 5º controle/rota, que disparou F2/F3 e F5; `src/components/campaign/municipality/MunicipalityListLevelControl.tsx` é o call site de submit explícito na folha
- `src/components/campaign/shared/CampaignCellEditOverlay.tsx` e `src/components/campaign/municipality/MunicipalityListSignalControl.tsx` — a casca de F4/F5 e o precedente de footer que ela ainda não expressa
- AGENTS.md — abstrações precisam de 3+ call sites (aqui há exatamente 3 para F1, 5 para F2/F3)

View file

@ -112,7 +112,7 @@ Depth check: reusa `Drawer` shadcn + padrão B26; não cria design system de ove
- ~~**Tooltip/tap da Classe no card mobile**~~ — **fechado no critique da própria sessão** (ver As-built): o `sr-only` sozinho deixava o veredito inalcançável no toque, o que contraria `DESIGN.md`.
- ~~**Drawer para votos estimados** no card~~ — **entrou nesta entrega** por decisão do usuário; foi o 3º call site que justificou extrair a casca.
- **Slot `footer` + `formId` na casca, para o Sinal (B26) migrar.** Gatilho: um 2º controle precisar de submit explícito num Drawer, ou o Sinal divergir visualmente dos outros três.
- ~~**Slot `footer` + `formId` na casca, para o Sinal (B26) migrar.**~~ **Gatilho disparado em 2026-07-27**, na triage de fechamento do **E14 ✓**: `MunicipalityListLevelControl` é o 2º controle de submit explícito e migrou para a casca sem o slot, o que na folha pina "Fechar" e empurra "Registrar movimento" para baixo da dobra — a ação que descarta uma justificativa digitada ficou mais alcançável que a que a registra. Virou **F5** do **B32+** ([escala-dry-pos-b32.md](escala-dry-pos-b32.md)), junto do F4 desta mesma casca, e não um ID novo.
- **Montar o Drawer só no primeiro `open`** (`everOpened`, não `open`). Gatilho: a lista mobile aparecer num profile de render, ou o caminho `pagination: false` (435 linhas × 4 overlays) passar a ser rota quente. Números medidos no As-built.
- **`prefers-reduced-motion` no `Drawer`.** O `Dialog`/`Sheet` do kit também não respeita; a animação de entrada é do base-ui e vem do kit, então a correção é do componente `ui/`, não deste item. Gatilho: qualquer trabalho de acessibilidade de movimento no kit.
- **`DrawerVirtualKeyboardProvider`.** Sem ele, o teclado do Android pode cobrir os inputs de votos dentro da folha; hoje mitigado por não focar campo na abertura. Gatilho: relato de campo em Android.

File diff suppressed because one or more lines are too long