diff --git a/.cursor/rules/campanha-edit-where-you-see.mdc b/.cursor/rules/campanha-edit-where-you-see.mdc index 528978e6..e33b68af 100644 --- a/.cursor/rules/campanha-edit-where-you-see.mdc +++ b/.cursor/rules/campanha-edit-where-you-see.mdc @@ -6,14 +6,14 @@ alwaysApply: false # Edit where you see (`/campanha`) -Canonical principle: `PRODUCT.md` § Design Principles — **Edit where you see**. Precedente: B9 (`PlazaList*Control` + `plazaStaffFormActions`). +Canonical principles: `PRODUCT.md` § Design Principles — **Edit where you see** + **Auto-save, no Save button**. Precedente: B9 (`PlazaList*Control` + `plazaStaffFormActions`). When adding or changing a **staff** (coordinator / advisor) view that displays mutable campaign data: 1. **Default to in-context edit** if the role can update that field — list cell, detail card, queue row — not a dead readout that only links to `/editar`. 2. **Reuse existing server actions** and the **same access** as the full form. Thin route wrappers OK; do not invent a generic quick-edit service (<3 call sites → named controls). -3. **UX pattern:** named Popover / select / short number input per affordance; touch `min-h-11`; save → server revalidate / RSC refresh. Prefer Enter + explicit save over accidental blur writes on mobile. -4. **Keep `/editar` (or long Sheet)** for multi-field strategy forms, rare fields, and flows that need a note or confirmation. +3. **UX pattern:** named Popover / select / short number input per affordance; touch `min-h-11`. **Auto-save** on select change, blur, or short debounce — no “Salvar” button on single-field / in-context edits. Show pending / saved / error on the control (Feel the action). Then server revalidate / RSC refresh. +4. **Keep `/editar` (or long Sheet) + explicit submit** only for multi-field strategy forms, rare fields, and flows that need a note, confirmation, or legal consent. 5. **Never** expose staff-only fields to `leader`. View models stay role-split. ## Anti-goals @@ -22,7 +22,10 @@ When adding or changing a **staff** (coordinator / advisor) view that displays m - Always-mounted inputs on every list row - Generic `EditableCell` / contentEditable mass edit - Optimistic UI that skips server refresh when the list is source of truth +- Save buttons on Popovers / quick-edit cells “just in case” +- Silent auto-save with no status feedback +- Saving on every keystroke for free-text without debounce ## Plan / Impeccable brief -For UI items (B/C/D): answer explicitly — _does this surface need in-context edit?_ Cite this principle and B9 when yes; name rabbit hole “spreadsheet mode” when scoping. +For UI items (B/C/D): answer explicitly — _does this surface need in-context edit?_ Cite these principles and B9 when yes; name rabbit hole “spreadsheet mode” when scoping. Prefer auto-save over an explicit Save button unless the write is multi-field / confirmatory / consent. diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 604c8049..1549d379 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,8 +7,10 @@ alwaysApply: false ## Status +- **A10 entregue em código (2026-07-23; deploy pendente com remodelagem):** cenários pessimista/média/otimista em `votePledge.estimatedVotes` e `plaza.expectedVotes` (groups + migration `20260723_124200_add_vote_estimate_scenarios`, backfill scalar→`central`); agregação por cenário em `votePledgeData`/`plazaMapData`/overview/dashboard; seletor de cenário no mapa 2026; lista/dashboard com média + faixa; liderança intacta (`declaredVotes` único). Plano: [`cenarios-estimativa-votos.md`](../../../docs/plans/cenarios-estimativa-votos.md). Desbloqueia **E8**. +- **Polish overview “Votos estimados” (2026-07-23):** `PlazaEstimateScenarioProvider` sincroniza Cenário do mapa com overview + células da lista; seletor Cenário visível em qualquer ano (hint quando ≠2026); labels `Média nas Praças filtradas` + `Pessimista · Otimista`; `sem estimativa` sob o hero. Critique: `.impeccable/critique/2026-07-23T21-29-20Z__src-components-campaign-plazalistoverview-tsx.md`. +- **Cenário no overview (2026-07-23):** seletor relocado para a visão geral; no mapa só com Ano=2026; disclaimer inline → `CampaignInfoHint` (Popover `?`); flash no overview ao trocar. Critique: `.impeccable/critique/2026-07-23T21-52-39Z__src-components-campaign-plazamappanel-tsx-cenario.md`. **capture-review-debts:** 0 IDs no roadmap; flash compartilhado adiado no plano A10 (gatilho: 2º flash client-side em `/campanha`). - **Programa WhatsApp interno D3–D5 registrado (2026-07-23):** comunicação 1:1 staff↔lideranças via bridge não oficial (não WABA / não massa); **número pessoal** de cada assessor/CG com pareamento QR no app (parece chat pessoal — pedido produto); fatias D3 multi-sessão → D4 envio pela sessão do ator → D5 inbox da própria sessão→rascunhos; baixa/média prioridade; Consent `campanha-whatsapp-canal`. Plano-mestre: [`whatsapp-interno-campanha.md`](../../../docs/plans/whatsapp-interno-campanha.md). -- **A10 registrado no roadmap (2026-07-23):** cenários pessimista/média/otimista nas estimativas staff (`votePledge` + `plaza.expectedVotes` + lista/mapa); liderança continua com um `declaredVotes`; E8 passa a depender de A10. Plano: [`cenarios-estimativa-votos.md`](../../../docs/plans/cenarios-estimativa-votos.md). Fonte: feedback coordenação geral (áudio em `docs/general-coordinator-interview/`). - **Discovery "Inteligência de campanha" aprovado (2026-07-21):** ciclo literatura→persona→entrevista sintética concluído e colaborado; embasamento canônico em [`docs/research/`](../../../docs/research/) (compêndio ~67 fontes, persona, relatório com kernel Rumelt + playbook de 25 padrões dado→decisão P/T/I/J/K + anti-goals). Programa de produto registrado no roadmap como **E8–E15 + B13 + C12** ([plano-mestre `inteligencia-campanha.md`](../../../docs/plans/inteligencia-campanha.md)) — conta da cadeira, fila de alocação, registro-fundação (trajetória de pledge/sinais/decisões), classificação relativa, escala relativa no mapa, níveis N0–N4, motor de sugestões (humano no loop), camada TI, planejador de giros, backtest. Decisões de produto travadas: % estadual absoluto é anti-métrica; denominadores usam válidos projetados (nunca aptos); sugestão nunca decide sozinha; nível de envolvimento não vaza para fora do staff. - **B12 entregue em código (2026-07-21; deploy pendente com remodelagem):** `fitToKeys` + `interactiveKeys` no Mapa das Praças (`plazasByIbgeCode`); `canonicalMapKeysKey`; hover stroke-only (fill inalterado); clear síncrono no mouseout; fit ao footprint filtrado sem re-zoom em Ano/Escala. Plano: [`aproximar-mapa-pracas.md`](../../../docs/plans/aproximar-mapa-pracas.md). - **B6 entregue em código (2026-07-21; deploy pendente com remodelagem):** `BahiaMap` setStyle incremental — layer GeoJSON estável entre troca de ano/métrica/escala; `pathByKeyRef` + restyle O(2) no hover/select; `fitBounds` só em `mode`/`highlightKeys`; helpers puros em `bahiaMapStyle.ts` + unit `bahiaMapStyle.unit.spec.ts`. Plano: [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md). Fecha débitos de perf de **B10** e **B11**. @@ -68,11 +70,12 @@ alwaysApply: false ## Decisões +- Em 2026-07-23, **auto-save sem botão Salvar:** campos de edição devem persistir automaticamente sempre que for seguro (change de select, blur ou debounce curto); sem botão “Salvar” em edição in-context / campo único. Submit explícito só para formulários multi-campo atômicos, nota/confirmação ou consentimento. Princípio 4 de [`PRODUCT.md`](../../../PRODUCT.md); regra Cursor [`.cursor/rules/campanha-edit-where-you-see.mdc`](../campanha-edit-where-you-see.mdc). **Rejeitado / supersede:** preferência B9 por Enter + botão Salvar em quick-edit; Save “por precaução” em Popover. - Em 2026-07-23, **WhatsApp interno 1:1 (D3–D5):** bridge não oficial; **sessão = número pessoal** de cada `coordinator`/`advisor` (QR no `/campanha`, estilo WhatsApp Web); liderança recebe no chat de sempre; Teqo = system of record; humano no loop em D5; sidecar multi-sessão. **Rejeitado:** WABA; massa; chip institucional único; auto-commit; inbox cruzado CG↔assessores na v1. Cortável (`wa.me`). - Em 2026-07-23, **estimativas staff em três cenários (A10):** pessimista / média (`central`) / otimista no pledge e no total da Praça; liderança declara um valor; default de leitura = média; não fundir com `voteGoals`. **Rejeitado:** um único estimado; expor faixa à liderança; três choropleths. Desbloqueia E8. - Em 2026-07-21, **embasamento de produto/domínio via `docs/research/`:** decisões de análise territorial e mapa citam o relatório de discovery (aprovado 2026-07-21) antes de inventar métrica nova. Regras derivadas: leitura relativa (quantis/LQ/rank/captura) sobre % absoluto; limiares defesa/ataque 35/20/10 degeneram para DF e serão recalibrados com âncoras relativas (E10); roll-off (brancos+nulos DF − majoritária) é o headroom de orientação; abstenção estrutural corrige denominador (válidos projetados) e não é mercado; agregação regional default = Território de Identidade com salvaguardas MAUP (praça manda no voto, TI na logística). **Rejeitado:** cartogramas; pesquisa estadual de intenção para DF; persuasão de território hostil como funcionalidade. - Em 2026-07-21, **toolchain TypeScript dual:** `typescript` → `@typescript/typescript6@6.0.2` (API/editor/ESLint + `tsc6`); `typescript-7` → `typescript@7.0.2` (Go `tsc` para `pnpm typecheck`). Motivo: typescript-eslint peer `<6.1.0` e API estável só em TS ~7.1. Não usar `@typescript/native-preview`/`tsgo` (canal nightly). `typecheck:tsc6` existe para paridade. Ambient CSS/SCSS em `src/css-modules.d.ts` (TS6 `noUncheckedSideEffectImports`). -- Em 2026-07-21, **decisão de produto (Field Desk):** em `/campanha`, superfícies staff de visualização que mostram dados mutáveis devem oferecer edição no contexto quando o access permitir (“edit where you see”) — princípio 3 de [`PRODUCT.md`](../../../PRODUCT.md); regra Cursor [`.cursor/rules/campanha-edit-where-you-see.mdc`](../campanha-edit-where-you-see.mdc). Precedente: **B9** ([`edicao-rapida-lista-pracas.md`](../../../docs/plans/edicao-rapida-lista-pracas.md) — `PlazaList*Control` + `plazaStaffFormActions`). **Rejeitado:** lista/detalhe só-leitura com toda mutação em `/editar`; data-grid / planilha full-row; service genérico de “quick edit”. `/editar` e Sheets longos ficam para formulários multi-campo, campos raros e fluxos com nota/confirmação. Liderança continua no escopo já definido (sem campos staff-only). +- Em 2026-07-21, **decisão de produto (Field Desk):** em `/campanha`, superfícies staff de visualização que mostram dados mutáveis devem oferecer edição no contexto quando o access permitir (“edit where you see”) — princípio 3 de [`PRODUCT.md`](../../../PRODUCT.md); regra Cursor [`.cursor/rules/campanha-edit-where-you-see.mdc`](../campanha-edit-where-you-see.mdc). Precedente: **B9** ([`edicao-rapida-lista-pracas.md`](../../../docs/plans/edicao-rapida-lista-pracas.md) — `PlazaList*Control` + `plazaStaffFormActions`). Complementado em 2026-07-23 pelo princípio **Auto-save, no Save button**. **Rejeitado:** lista/detalhe só-leitura com toda mutação em `/editar`; data-grid / planilha full-row; service genérico de “quick edit”. `/editar` e Sheets longos ficam para formulários multi-campo, campos raros e fluxos com nota/confirmação. Liderança continua no escopo já definido (sem campos staff-only). - Núcleo Eleitoral é a unidade operacional; Zona TSE é uma referência oficial adicional. - Todo núcleo exige geografia; natureza organizativa e setor são dimensões complementares. - Exclusão no app é substituída por arquivamento. diff --git a/.impeccable/critique/2026-07-23T21-29-20Z__src-components-campaign-plazalistoverview-tsx.md b/.impeccable/critique/2026-07-23T21-29-20Z__src-components-campaign-plazalistoverview-tsx.md new file mode 100644 index 00000000..bc3f78d1 --- /dev/null +++ b/.impeccable/critique/2026-07-23T21-29-20Z__src-components-campaign-plazalistoverview-tsx.md @@ -0,0 +1,102 @@ +--- +target: /campanha/pracas overview Votos estimados no conjunto +total_score: 20 +p0_count: 0 +p1_count: 3 +p2_count: 2 +p3_count: 1 +timestamp: 2026-07-23T21-29-20Z +slug: src-components-campaign-plazalistoverview-tsx +--- +Method: dual-agent (A: c0c0346d-83f2-4aa7-b728-c883635b063e · B: 104c4dcf-5d13-4351-b6fe-db8d2d632179) + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 2 | Número muda com filtros, mas nunca diz que é o cenário Média; mapa pode divergir | +| 2 | Match System / Real World | 2 | “Faixa” e “no conjunto” não nomeiam pessimista–otimista nem o recorte filtrado | +| 3 | User Control and Freedom | 3 | Agregado read-only correto; filtros controlam o conjunto; sem seletor de cenário no strip | +| 4 | Consistency and Standards | 2 | Dashboard: “Votos estimados” + célula própria p/ sem estimativa; lista empacota no meio | +| 5 | Error Prevention | 2 | `—` quando total=0; faixa some se falta um extremo (pós-backfill só central) | +| 6 | Recognition Rather Than Recall | 1 | Exige lembrar A10: default=média; Faixa=P–O; conjunto=filtrado | +| 7 | Flexibility and Efficiency | 2 | Denso p/ Alex; cenário só no mapa / Popover da linha | +| 8 | Aesthetic and Minimalist Design | 3 | Field Desk limpo; ênfase justificada; leve hero-KPI | +| 9 | Error Recovery | 2 | Qualidade do total (`sem estimativa`) enterrada em Declarações | +| 10 | Help and Documentation | 1 | Sem microcopy “Média” / “Pessimista–Otimista” | +| **Total** | | **20/40** | **Acceptable — craft visual ok; informação do cenário fraca** | + +## Anti-Patterns Verdict + +**LLM assessment:** Não é slop SaaS genérico (sem purple, side-stripe, card grid). Field Desk se lê: stone, Inter, priority lift permitido. Resíduo de **hero-metric template**: número grande enfatizado + detalhe tipográfico fraco, sem o vocabulário A10 (Média / Pessimista / Otimista) que forms e mapa já usam. + +**Deterministic scan:** CLI `detect.mjs` em `PlazaListOverview.tsx` + `CampaignMetricStrip.tsx` → **exit 0, `[]`** (0 findings). + +**Visual overlays:** Login wall em `/campanha/pracas` → overlay só no login (2 warnings fora de escopo: `flat-type-hierarchy`, `nested-cards` shadcn). Strip autenticado não inspecionado ao vivo. Screenshot: `.impeccable/assessment-b-campanha-pracas-login.png`. + +## Overall Impression + +O strip está certo em *estrutura* A10 (média grande + faixa secundária, não três KPIs) e no lugar certo (acima da tabela, no recorte filtrado). Falha em *rotular a semântica*: o número hero não diz “Média”, “Faixa” não diz pessimista–otimista, e o mapa acima pode mostrar outro cenário sem o strip confessar o lock em `central`. Em reunião, Marcos não sabe se está olhando chão, caso-base ou teto. + +## What's Working + +1. **Priority strip 3 células** — overview acima da lista, não card grid; lift alinhado a DESIGN.md. +2. **Rollup filtrado** — `rollupPlazaStaffVotes` torna “conjunto” funcionalmente o recorte, mesmo com label mole. +3. **Restrição de produto** — sem % estadual, sem gauge, sem três KPIs; absolute votos + tabular nums. + +## Priority Issues + +### [P1] Número grande sem rótulo “Média” +- **Why:** Default A10 é `central`; a UI só diz “Votos estimados no conjunto”. Coordenação pergunta “isso é o otimista?”. +- **Fix:** Label ou detail explícito: “Média” / “Cenário média (padrão)”. +- **Suggested command:** `/impeccable clarify` + +### [P1] “Faixa X–Y” sem pessimista–otimista +- **Why:** Pode ser CI, meta ou goals; conflita com vocabulário A10 já nos forms/mapa. +- **Fix:** “Pessimista X · Otimista Y” ou mini `VoteEstimateScenarioStrip` endpoints. +- **Suggested command:** `/impeccable clarify` + +### [P1] Mapa Cenário vs strip dessincronizado +- **Why:** Mapa (acima) troca P/M/O; strip fica em central sem cue — duas verdades na mesma scroll. +- **Fix:** Âncora explícita (“sempre média”) **ou** strip segue o cenário do mapa (estado local compartilhado). +- **Suggested command:** `/impeccable layout` + +### [P2] `sem estimativa` enterrado em Declarações +- **Why:** Qualidade do hero number some no meio da célula; dashboard já tem célula própria. +- **Fix:** Footnote sob o total ou alinhar layout ao dashboard. +- **Suggested command:** `/impeccable layout` + +### [P2] “No conjunto” subespecificado +- **Why:** Não diz “Praças filtradas”; fácil ler como estadual/escopo total. +- **Fix:** “Média nas Praças filtradas” (resolve P1+P2 de naming). +- **Suggested command:** `/impeccable clarify` + +### [P3] Linguagem visual ≠ linhas da tabela +- **Why:** Linhas usam ScenarioStrip; overview só tipografia. +- **Fix:** Strip compacto endpoints no detail. +- **Suggested command:** `/impeccable polish` + +## Persona Red Flags + +**Alex (CG):** Usa o número no briefing sem notar mapa em pessimista vs strip em média. + +**Jordan (assessor novo):** “Faixa” + “conjunto” + número sem “Média” → confunde com `voteGoals`. + +**Sam (a11y):** Detail em `

` solto; nomes de cenário ausentes do summary falado do hero. + +**Marcos (CG em reunião):** Precisa chão/caso-base/teto num glance; recebe KPI absoluto + faixa críptica. + +## Minor Observations + +- Chrome duplo (section priority + dl card) um pouco pesado. +- Dashboard vs lista: labels e packing de `missingEstimateCount` divergem. +- `staffVoteTotal > 0 ? format : '—'` trata zero como vazio. +- Pós-backfill só-central: some a faixa e o número parece mais certo do que é. +- Agregado read-only correto (edit na linha) — sem violação Edit-where-you-see. + +## Questions to Consider + +- O strip é âncora fixa de caso-base ou gêmeo esquecido do seletor do mapa? +- Trocar “Faixa” por “Pessimista · Otimista” ensina o modelo sem custar density? +- “Média nas Praças filtradas” aposenta três ambiguidades de uma vez? +- `sem estimativa` deveria ser footnote do hero, não do meio? diff --git a/.impeccable/critique/2026-07-23T21-52-39Z__src-components-campaign-plazamappanel-tsx-cenario.md b/.impeccable/critique/2026-07-23T21-52-39Z__src-components-campaign-plazamappanel-tsx-cenario.md new file mode 100644 index 00000000..c7d766e0 --- /dev/null +++ b/.impeccable/critique/2026-07-23T21-52-39Z__src-components-campaign-plazamappanel-tsx-cenario.md @@ -0,0 +1,92 @@ +--- +target: /campanha/pracas Cenário selector + disclaimer +total_score: 18 +p0_count: 0 +p1_count: 2 +p2_count: 2 +p3_count: 1 +timestamp: 2026-07-23T21-52-39Z +slug: src-components-campaign-plazamappanel-tsx-cenario +--- +Method: dual-agent (A: c3613972-8d1f-41bb-b210-501b0ddc14c9 · B: 8ae72814-5de3-4010-9ebb-7c9d64d8ccec) + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 1 | Ano default 2022: mudar Cenário não pinta o mapa; efeito no overview abaixo do fold | +| 2 | Match System / Real World | 1 | “overview” / “preenchimento” ≠ linguagem de campo; toolbar diz “filtro do mapa” | +| 3 | User Control and Freedom | 3 | Três opções reversíveis; baixo risco | +| 4 | Consistency and Standards | 2 | Mesmo NativeSelect que Ano/Escala, mas escopo de página (não só mapa) | +| 5 | Error Prevention | 1 | Posição convida o modelo errado; disclaimer é pós-fato | +| 6 | Recognition Rather Than Recall | 2 | Labels P/M/O ok; regra “só 2026” exige ler o rodapé | +| 7 | Flexibility and Efficiency | 2 | Sync global é eficiente se entendido; sem atalho Ano→2026 | +| 8 | Aesthetic and Minimalist Design | 2 | Quarto controle + disclaimer xs no estado comum (Ano≠2026) | +| 9 | Error Recovery | 2 | Mapa “não fez nada” sem live region no overview | +| 10 | Help and Documentation | 2 | Hint existe; copy de engenheiro; sempre ligada no first paint | +| **Total** | | **18/40** | **Poor — labels A10 ok; assento + disclaimer quebram o controle** | + +## Anti-Patterns Verdict + +**LLM assessment:** Não é slop SaaS. Padrão Field Desk (label + NativeSelect + min-h-11). Falha = **apology UI**: controle de escopo de página sentado na toolbar do mapa, coberto por nota de rodapé. “overview” / “preenchimento” soam PR comment, não Field Desk. + +**Deterministic scan:** CLI `detect.mjs` em `PlazaMapPanel.tsx` + `PlazaEstimateScenarioContext.tsx` → **exit 0, `[]`**. + +**Visual overlays:** Autenticado em `/campanha/pracas`. Overlay runtime 12 hits (maioria FP de shell/Leaflet/Inter). Disclaimer medido: 70 chars, wrap estreito ~176×84. Screenshot: `.impeccable/assessment-b-campanha-pracas.png`. Cenário + disclaimer visíveis com Ano≠2026. + +## Overall Impression + +A10 labels e sync via context estão certos. O bug de UX é estrutural: **Cenário mora na fila Ano/Escala/Comparar**, então promete pintar o mapa; no first paint (Ano=2022) só move overview/lista — e o disclaimer admite isso em jargão. Corrigir assento > alongar copy. + +## What's Working + +1. **Pessimista / Média / Otimista** — vocabulário A10 certo; default Média. +2. **Um cenário sincronizado** (context) — melhor que três toggles órfãos. +3. **Disclaimer admite o acoplamento** — honestidade > mentira silenciosa; o lugar da honestidade é o problema. + +## Priority Issues + +### [P1] Assento na toolbar do mapa implica choropleth quando Ano≠2026 +- **Why:** First paint comum: muda Cenário → mapa igual → sensação de broken. +- **Fix:** Relocar ao overview; **ou** desabilitar/ghost + explicar; **ou** Cenário força Ano→2026; **ou** esconder no mapa quando ≠2026 *e* hospedar o controle onde o efeito é visível. +- **Suggested command:** `/impeccable layout` (ou `/impeccable shape`) + +### [P1] Disclaimer em português de engenheiro +- **Why:** “overview” não está na UI; “preenchimento” é jargão de mapa. +- **Fix:** Uma linha de resultado (“Muda os totais acima da lista. No mapa, só com Ano 2026.”) — ou matar a linha relocando o controle. +- **Suggested command:** `/impeccable clarify` / `/impeccable distill` + +### [P2] Feel the action: efeito fora do viewport +- **Why:** Mapa acima; overview/lista abaixo. Em mobile parece no-op. +- **Fix:** Highlight/pending no overview; ou co-localizar o controle. +- **Suggested command:** `/impeccable animate` + layout + +### [P2] Always-show na fila do mapa vs hide +- **Why:** Sync global é racional; *nesta* fila treina o affordance errado. +- **Fix:** Não esconder sem relocação; se global, não vestir como filtro do mapa. +- **Suggested command:** `/impeccable shape` + +### [P3] Densidade Ano / Escala / Cenário / Comparar +- **Why:** Quarto peer + disclaimer wrap em narrow (Casey). +- **Suggested command:** `/impeccable quieter` + +## Persona Red Flags + +**Alex:** Quer pessimista no footprint; mapa não muda → desconfiança. +**Jordan:** “overview” / “preenchimento” opacos. +**Casey:** Disclaimer engole vertical; efeito abaixo do mapa. +**Sam:** Disclaimer sem `aria-describedby`; sem live region nos totais. + +## Minor Observations + +- Hide em compare alinhado a Escala — ok. +- Readout 2026 já inclui cenário — forte quando Ano certo. +- Ordem Map → filtros → overview inverte o controle global no mapa. +- Polish anterior priorizou discoverability do sync às custas da honestidade do mapa. + +## Questions to Consider + +- Se o job é overview+lista, por que o controle vive na fila que pinta o mapa? +- Alex prefere perder o disclaimer ou perder Cenário da toolbar? +- Auto Ano→2026 é Feel the action ou sequestro da leitura histórica? +- Se o disclaimer é o estado comum do first paint, a IA default não está errada? diff --git a/AGENTS.md b/AGENTS.md index e91a64d4..ca8ace16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-23):** **Export CSV de assinaturas e contatos** no admin Payload — `@payloadcms/plugin-import-export@3.82.0` habilitado em `signature` e `contact` (`import: false`, export CSV síncrono); flatten de `contact`/`petition` via `toCSV` em [`src/utilities/signatureExport.ts`](src/utilities/signatureExport.ts) (só assinaturas); `consent` excluído do export de assinaturas; collections `exports`/`imports` com access `isPayloadAdmin`; migration `20260723_025513_add_import_export_plugin`. Plano: [`docs/plans/exportar-csv-assinaturas.md`](docs/plans/exportar-csv-assinaturas.md). +**Recently resolved (2026-07-23):** **A10** Cenários de estimativa (pessimista/média/otimista) — `votePledge.estimatedVotes` e `plaza.expectedVotes` viram groups (`pessimistic|central|optimistic`); migration `20260723_124200_add_vote_estimate_scenarios` (backfill scalar→`central`); agregação por cenário em `votePledgeData` com default `central`; mapa 2026 com `values2026ByScenario` + seletor local; lista/overview/dashboard com média + faixa secundária; liderança continua com um `declaredVotes` sem ver estimativas. Plano: [`docs/plans/cenarios-estimativa-votos.md`](docs/plans/cenarios-estimativa-votos.md). Desbloqueia **E8**. + ## Posts & Tags (news / publications) The public news/publications system is backed by two collections (both in the `Publicações` admin group) plus the helpers in `src/utilities/posts.ts`. @@ -118,7 +120,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k - **Praça definition:** 436 seeded rows — one per Bahia municipality (415), except Salvador (19 zone Praças, ZE 1–19) and Camaçari (2 zone Praças, ZE 170/171). TSE zones shared across municipalities (141, 162) or leaking outside the RMS (128, 185, 200) are cut at the municipal boundary, so every Praça maps to exact `cityCode × zones` election cells. The canonical catalog is `src/lib/plazaCatalog.ts` (slug/name identity frozen by `tests/fixtures/plaza-catalog.snapshot.json`); the migration `20260721_020109_remodel_plazas` seeds it. Nobody creates/edits plaza geography in the app — identity fields are admin-only; staff edit only strategy fields (goals, priority, intel, political trend) and the coordinator assigns `advisors`. - **Collections (admin group `Campanha`):** `plaza` (strategy + advisors + manual `politicalTrend`), `leadership` (ONE per person: `contact` UNIQUE + `plazas` hasMany + `organizations` hasMany + staff-only `supportStatus`), `votePledge` (leadership×plaza UNIQUE: `declaredVotes` leader-visible vs `estimatedVotes` staff-only), `organization` (sindicatos/associações/movimentos with reverse leadership + actionPlan views), `campaignDemand` (leader-opened workflow `aberta → em_analise → escalada → aprovada|rejeitada` with staff-only `cost`/`receipts`), `plazaUpdate` (immutable field reports maintaining `plaza.lastUpdateAt` under advisory lock), `campaignInvite` (unchanged: single-use SHA-256 hashes hanging off `leadership`), plus adapted `supporter` (optional `plaza`) and `actionPlan` (single `plaza`, `advisors`, `organizations`, `deputyPresent`, `resultSummary`/`resultMedia`). - **Roles:** `coordinator` ("Coordenador Geral") sees/manages everything and is the only one who assigns advisors and decides escalated demands; `advisor` ("Assessor") sees and manages ONLY plazas listed in `plaza.advisors` (list, map, dashboard, leaderships, pledges, demands are all scoped); `leader` ("Liderança") sees only plazas linked through their engaged leadership, declares own votes, opens demands, and writes own updates. -- **Vote pledge asymmetry (product-critical):** the leader INFORMS `declaredVotes` per linked plaza and may update it; staff record `estimatedVotes`/`estimateNote`, which field access denies to leaders (same technique as `supportStatus`). Leader-facing view models never aggregate estimates; staff aggregates use `estimatedVotes ?? declaredVotes` (`src/utilities/votePledgeData.ts`). Guarded by `tests/int/campaignVotePledge.int.spec.ts`. +- **Vote pledge asymmetry (product-critical):** the leader INFORMS `declaredVotes` per linked plaza and may update it; staff record `estimatedVotes` (group: pessimistic/central/optimistic) and `estimateNote`, which field access denies to leaders (same technique as `supportStatus`). Leader-facing view models never aggregate estimates; staff aggregates use `estimated[S] ?? declared` per scenario, default read `central` (`src/utilities/votePledgeData.ts`). Guarded by `tests/int/campaignVotePledge.int.spec.ts`. - **Political trend:** manual conjuncture reading on the plaza (staff-only group `politicalTrend`: `favoravel|neutra|desfavoravel` + note + derived author/date). The numeric series is deliberately labeled "Evolução de votos" in the UI to keep "tendência" for the political reading. - **Elections intelligence:** plaza geography → election cells via `src/utilities/plazaElectionGeography.ts`; baseline + Solla series 2014/2018/2022 (`plazaElectoralBaseline.ts`); multi-candidate comparison table on the plaza detail (`plazaCandidateComparison.ts`, up to 5 candidates via `?compare=`); map (`plazaMapData.ts` + `PlazaMapPanel`) colors municipalities by year (2026 = pledge estimates) with scale selector **Total (votos)** | **% dos válidos** (default %; fixed 0–100% domain via `validVotesByYear` + `scaleMax` on `BahiaMap`; 2026 uses 2022 valid votes as denominator; compare mode hides % and stays absolute diff) and has a diverging compare mode (campaign red `#c51414` where Solla leads ↔ white tie ↔ blue where the compared candidate leads). Zone Praças aggregate into the municipality polygon with a per-zone breakdown list (zone polygons remain a rabbit hole). - **URLs:** `/campanha/pracas[/slug]` (seeded slugs from the catalog), `/campanha/liderancas[/id]`, `/campanha/organizacoes[/slug]`, `/campanha/demandas[/slug]`, `/campanha/planos`, `/campanha/apoiadores`. Route params stay English keys; slugs/enums stay pt data values. diff --git a/PRODUCT.md b/PRODUCT.md index bdd5055a..87cc0c0c 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -43,10 +43,11 @@ Not generic SaaS dashboards (purple gradients, hero-metric templates, identical 1. **Sovereignty as the horizon** — own audience, data, and ops over time; Big Tech shortcuts (WhatsApp kits, share links) are fine for now when they help mobilization, but design should keep the campaign’s own records and workflows as the source of truth and leave room to reduce platform dependency later. 2. **Clarity under pressure** — every screen earns its keep for field use; prefer plain language and obvious next actions over decoration. 3. **Edit where you see** — on `/campanha` staff surfaces (coordinator / advisor), mutable data that the role can update should be editable in context whenever it fits (list cell, detail card, queue row), so assessors and the general coordinator do not burn time navigating to a separate edit screen for every correction. Prefer named Popovers / selects / short inputs that reuse existing server actions and the same access rules as the full form. Keep dedicated `/editar` (or long Sheets) for multi-field strategy forms, rare fields, and anything that needs a note or confirmation flow. Leadership paths stay read-or-declare as already scoped — never expose staff-only fields. Anti-goals: full-row spreadsheet / data-grid mode; always-mounted inputs on every row; a second generic “editable cell” design system; optimistic _writes of list/aggregate results_ that skip server refresh when the list is the source of truth (optimistic control state is fine — see Feel the action). -4. **Depth and simplicity as peers** — staff workflows can be rich; leadership paths stay light and phone-first without becoming a second-class product. -5. **Intelligence serves organization** — electoral baseline and insights exist to decide where to act, not to impress. The product delivers intelligence, not a fancy spreadsheet: it computes the bulk of the analysis (vigilance across 436 Praças, structural differential diagnosis, a prioritized decision queue with suggested action menus) so staff spend judgment, not arithmetic. Decision metrics are **relative and local** — coverage of per-Praça goals by auditable pledges, capture of the campo's majoritarian ceiling, quantiles/LQ of the candidate's own distribution — never absolute statewide % (a DF seat costs ~80–150k votes; the statewide record is ~5% of valid votes, so absolute scales flatten into noise). Suggestions are grounded in `docs/research/` (approved discovery, 25 data→decision patterns) and are always a menu for a human: the product never auto-decides, and each decision — including the discarded alternative reading — is recorded ex-ante for the post-election backtest. Anti-goals: raw-count KPIs, 0–100% fixed choropleth scales, "available votes" reservoirs from abstention/null tallies, suggestion-as-rule. -6. **Inspire without kitsch** — socialist and mobilizing in spirit; never shouty campaign brochure aesthetics. -7. **Feel the action** — every user-initiated action (filter, save, URL-driven refresh, opening a heavy sheet) must give **immediate** feedback on the control or surface that was touched — under ~100ms perceived response. Prefer optimistic UI for the _control value_ (select shows the chosen option at once; button shows pending) while the _result_ (list, map, KPIs, detail payload) shows honest pending (`aria-busy`, live region, opacity/skeleton on the region that changes) until the server/RSC catches up. Applies to `/campanha` first (field pressure) and to public interactive flows the same way. Anti-goals: silent waits where the control stays on the old value until navigation finishes; full-page spinner as the only feedback; fake progress bars; omitting accessible busy/live announcements. +4. **Auto-save, no Save button** — editable fields should persist automatically whenever it is safe (on select change, blur, or short debounce — pick what fits the control). Do not require an explicit “Salvar” / Save button for single-field or in-context edits. Pair with Feel the action: optimistic control state + honest pending / saved / error feedback on the field itself. Keep an explicit submit only when the write must be atomic across several fields, needs a note/confirmation, or is a legal consent / irreversible flow. Anti-goals: silent auto-save with no status; saving on every keystroke for free-text without debounce; Save buttons on Popovers and quick-edit cells “just in case.” +5. **Depth and simplicity as peers** — staff workflows can be rich; leadership paths stay light and phone-first without becoming a second-class product. +6. **Intelligence serves organization** — electoral baseline and insights exist to decide where to act, not to impress. The product delivers intelligence, not a fancy spreadsheet: it computes the bulk of the analysis (vigilance across 436 Praças, structural differential diagnosis, a prioritized decision queue with suggested action menus) so staff spend judgment, not arithmetic. Decision metrics are **relative and local** — coverage of per-Praça goals by auditable pledges, capture of the campo's majoritarian ceiling, quantiles/LQ of the candidate's own distribution — never absolute statewide % (a DF seat costs ~80–150k votes; the statewide record is ~5% of valid votes, so absolute scales flatten into noise). Suggestions are grounded in `docs/research/` (approved discovery, 25 data→decision patterns) and are always a menu for a human: the product never auto-decides, and each decision — including the discarded alternative reading — is recorded ex-ante for the post-election backtest. Anti-goals: raw-count KPIs, 0–100% fixed choropleth scales, "available votes" reservoirs from abstention/null tallies, suggestion-as-rule. +7. **Inspire without kitsch** — socialist and mobilizing in spirit; never shouty campaign brochure aesthetics. +8. **Feel the action** — every user-initiated action (filter, save, URL-driven refresh, opening a heavy sheet) must give **immediate** feedback on the control or surface that was touched — under ~100ms perceived response. Prefer optimistic UI for the _control value_ (select shows the chosen option at once; button shows pending) while the _result_ (list, map, KPIs, detail payload) shows honest pending (`aria-busy`, live region, opacity/skeleton on the region that changes) until the server/RSC catches up. Applies to `/campanha` first (field pressure) and to public interactive flows the same way. Anti-goals: silent waits where the control stays on the old value until navigation finishes; full-page spinner as the only feedback; fake progress bars; omitting accessible busy/live announcements. ## Accessibility & Inclusion diff --git a/docs/plans/cenarios-estimativa-votos.md b/docs/plans/cenarios-estimativa-votos.md index 69c86342..8a63dca5 100644 --- a/docs/plans/cenarios-estimativa-votos.md +++ b/docs/plans/cenarios-estimativa-votos.md @@ -1,7 +1,7 @@ # Cenários de estimativa de votos (pessimista / média / otimista) -Status: rascunho -Atualizado em: 2026-07-23 +Status: entregue (2026-07-23) +Atualizado em: 2026-07-23 _(entrega A10 + polish overview/Cenário; capture-review-debts: no-op roadmap, gatilho flash)_ Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A10) Impeccable: B — encaixe em forms/lista/mapa/dashboard já existentes (sem rota nova) Appetite: ~2 dias eng; migration (pledge + praça) + helpers de agregação + seletor de cenário nas superfícies staff @@ -56,15 +56,15 @@ E8 (conta da cadeira) ainda não começou e planeja Σ `estimatedVotes ?? declar - **Backfill na migration: escalar antigo → `central`; pessimista/otimista null.** Preserva o significado do número único já lançado. **Rejeitado:** copiar o mesmo valor nos três (inventa certeza); zerar tudo (apaga trabalho de campo). - **Ordem quando os três estão preenchidos: pessimista ≤ central ≤ otimista** (mesmo espírito de `voteGoals` / `voteGoals.ts`). **Rejeitado:** sem invariante (quebra leitura da faixa); forçar os três sempre preenchidos na v1 (fria adoção). - **Fallback por cenário:** `effective[S] = estimated[S] ?? declared`; total Praça `resolve[S] = expected[S] ?? Σ effective[S]`. Cenário ausente no pledge cai no declarado **daquele** pledge; Praça sem `expected[S]` usa só a soma daquele cenário. **Rejeitado:** “se falta pessimista, usar média” (mistura cenários); um único fallback compartilhado entre S. -- **Default de leitura = `central` (média); seletor de cenário no mapa e na lista/overview** (URL ou estado local no padrão Ano/Escala). **Rejeitado:** default otimista (infla a conta); três mapas lado a lado; só tooltip com faixa sem trocar o fill. +- **Default de leitura = `central` (média); seletor de cenário no mapa (e lista/overview se couber) em estado local**, no mesmo padrão de Ano/Escala do `PlazaMapPanel`. **Rejeitado:** default otimista (infla a conta); três mapas lado a lado; só tooltip com faixa sem trocar o fill; `?estimate=` na v1 (Ano/Escala não usam URL — só `compare` usa). - **Access:** inalterado — `canRead/ManageCampaignStaffField` nos grupos; leader redacted. **Rejeitado:** só coordinator escreve otimista. - **i18n e naming:** `pessimistic` / `central` / `optimistic`, `VoteEstimateScenario`, `resolvePlazaStaffVoteTotalForScenario`, `aggregatePledgesByPlaza` passa a expor totais por cenário; strings “Pessimista” / “Média” / “Otimista”. ## Questões em aberto -- **Seletor de cenário na URL (`?estimate=central`) ou só estado local no cliente?** **Opções:** A) query param (shareable, SSR) | B) estado local | C) cookie. **Recomendação:** A no mapa/lista (espelha `year`/`scale` e sobrevive a refresh RSC); default omitido = central. _(assumido — validar com produto)_ -- **Partial fill: salvar só média?** **Opções:** A) sim, nullable independente | B) exigir os três. **Recomendação:** A — ordem só quando ≥2 preenchidos e comparáveis; UI sugere preencher a faixa. -- **KPI do dashboard mostra um número ou a faixa?** **Opções:** A) só média + link | B) `pessimista–otimista` tipográfico | C) três KPIs. **Recomendação:** B (número médio + faixa secundária); C estoura density. +- **Seletor de cenário na URL (`?estimate=central`) ou só estado local no cliente?** **Opções:** A) query param (shareable, SSR) | B) estado local | C) cookie. **Recomendação:** **B** no mapa (e lista/overview se houver seletor) — `PlazaMapPanel` já guarda **Ano** e **Escala** em `useState` local; só `compare` vai na URL. Espelhar esse padrão evita RSC round-trip e inconsistência com os outros seletores. Default = `central` sem persistência. _(assumido — validar com produto; A só se share de cenário virar pedido explícito)_ **Rejeitado nesta revisão:** A “porque espelha year/scale” — afirmação defasada (year/scale não são URL). +- **Partial fill: salvar só média?** **Opções:** A) sim, nullable independente | B) exigir os três. **Recomendação:** A — ordem só quando ≥2 preenchidos e comparáveis; UI sugere preencher a faixa. _(fechado)_ +- **KPI do dashboard mostra um número ou a faixa?** **Opções:** A) só média + link | B) `pessimista–otimista` tipográfico | C) três KPIs. **Recomendação:** B (número médio + faixa secundária); C estoura density. _(fechado)_ ## Abordagem proposta @@ -76,7 +76,7 @@ flowchart LR plaza --> resolve["resolvePlazaStaffVoteTotalForScenario"] agg --> resolve resolve --> list["PlazaList + overview"] - resolve --> map["PlazaMapPanel + ?estimate="] + resolve --> map["PlazaMapPanel + estado local"] resolve --> dash["dashboard / E8"] forms["PledgeEstimateForm + Plaza*Votes"] --> pledge forms --> plaza @@ -85,13 +85,13 @@ flowchart LR Componentes: -- **`VotePledge` / `Plaza`** (`src/collections/VotePledge.ts`, `Plaza.ts`): substituir scalars por groups com três `number` min 0; access staff nos groups; hook de audit `estimatedAt`/`estimatedBy` quando qualquer subcampo muda; validação de ordem (reusar padrão de `voteGoals.ts` → `voteEstimate.ts`). -- **Migration** `pnpm migrate:create add_vote_estimate_scenarios`: criar colunas do group; `UPDATE … SET central = estimated_votes` (e idem `expected_votes`); dropar scalars; sem Consent. -- **`src/lib/schemas/votePledge.ts` / `plaza.ts`:** schemas Zod dos três; action `setVotePledgeEstimate` / `setPlazaExpectedVotes` passam a aceitar o trio (nullable). -- **`src/utilities/votePledgeData.ts`:** `PlazaPledgeAggregate` ganha `effectiveByScenario` + `declaredTotal`; `resolvePlazaStaffVoteTotal` vira variante por cenário; `rollupPlazaStaffVotes` default `central`. Depth: **não** criar `VoteEstimateService` — estender o módulo profundo existente. -- **`plazaMapData.ts` + `PlazaMapPanel`:** bundle 2026 indexado por cenário (ou recompute client-side a partir de três maps); seletor “Cenário” ao lado de Ano/Escala; readout mostra valor do cenário + faixa se completa. -- **UI:** `PledgeEstimateForm` três inputs; `PlazaStrategyForm` / `PlazaListExpectedVotesControl` trio (Popover pode empilhar 3 campos); `StaffPlazaVotesDisplay` / `PlazaPledgesPanel` / `PlazaList` leem cenário ativo + faixa; `DeclareVotesForm` intacto. -- **Testes:** int assimetria (leader não vê nenhum dos três); unit ordem + fallback por cenário; int migration backfill → central; mapa/lista usam default central. +- **`VotePledge` / `Plaza`** (`src/collections/VotePledge.ts`, `Plaza.ts`): substituir scalars por groups com três `number` min 0; access staff nos groups; hook de audit `estimatedAt`/`estimatedBy` quando qualquer subcampo muda; validação de ordem (reusar padrão de `src/utilities/voteGoals.ts` → helper fino `voteEstimate.ts` em `src/utilities/` ou `src/lib/` — só se ≥2 call sites). +- **Migration** `pnpm migrate:create add_vote_estimate_scenarios`: criar colunas do group (`estimated_votes_pessimistic|central|optimistic`, idem `expected_votes_*` no padrão de `vote_goals_*`); `UPDATE … SET …_central = estimated_votes` (e idem `expected_votes`); dropar scalars + índice antigo em `estimated_votes`; sem Consent. +- **`src/lib/schemas/votePledge.ts` / `plaza.ts`:** schemas Zod dos três; actions existentes `estimateVotes` / `setPlazaExpectedVotes` (+ form actions em `plazaStaffFormActions` / `pledgeFormActions`) passam a aceitar o trio (nullable). +- **`src/utilities/votePledgeData.ts`:** `PlazaPledgeAggregate` ganha `effectiveByScenario` + `declaredTotal`; `resolvePlazaStaffVoteTotal` / `rollupPlazaStaffVotes` com cenário (default `central`). Depth: **não** criar `VoteEstimateService` — estender o módulo profundo existente. +- **`plazaMapData.ts` + `PlazaMapPanel`:** bundle 2026 com valores por cenário (três maps no bundle — leitura local no seletor); seletor “Cenário” ao lado de Ano/Escala (**estado local**, como Ano/Escala); readout mostra valor do cenário + faixa se completa. +- **UI:** `PledgeEstimateForm` três inputs; `PlazaStrategyForm` / `PlazaListExpectedVotesControl` trio (Popover pode empilhar 3 campos); `StaffPlazaVotesDisplay` / `PlazaPledgesPanel` / `PlazaList` leem cenário ativo (default central) + faixa; `DeclareVotesForm` intacto. +- **Testes:** int assimetria (leader não vê nenhum dos três) em `campaignVotePledge.int.spec.ts`; unit ordem + fallback por cenário; int/unit backfill semântico → central; mapa/lista usam default central. - **Depth check:** reusar `plazaStaffFormActions`, `campaignAccess`, shells B9; espelhar invariante de `voteGoals.ts` em helper fino compartilhado só se ≥2 call sites. ## Dependências @@ -108,6 +108,18 @@ Componentes: - Alterar `voteGoals` ou fundir labels; previsão estatística; import CSV de faixas. - Expor estimativas à liderança; auto-preencher faixa a partir do declarado. +## Já resolvido no simplify/critique (não reabrir) + +- Overview: label por cenário ativo (`Média nas Praças filtradas`), endpoints `Pessimista · Otimista`, `VoteEstimateScenarioStrip`, `sem estimativa` sob o hero; sync via `PlazaEstimateScenarioProvider`. +- Cenário: seletor no overview; no mapa só com Ano=2026; disclaimer inline → `CampaignInfoHint` (`?` Popover); flash + `aria-live` no overview ao trocar. +- Cleanup `/simplify`: dropar `staffVoteRange` / `staffVoteTotal` mortos nos DTOs overview/dashboard; `formatElectionNumber`; context sem `useCallback` extra. + +## Explicitamente fora (capture-review-debts 2026-07-23) + +- Simplify amplo do restante A10 (forms/pledges/migration) sem dor concreta — sem `escala-dry-pos-a10`. +- Trocar `CampaignInfoHint` por `FieldDescription` — affordance errado (hint dismissível vs ajuda estática). +- Remover `rollup.staffVoteTotal` — ainda usado por testes/API do rollup. + ## Rabbit holes - **Fundir com Bom/Regular/Mínimo.** Se alguém “aproveitar o group que já existe”: apaga meta×estimativa. **Mitigação:** labels e identificadores distintos; teste que `voteGoals` não é lido pelo resolver de estimativa. @@ -120,16 +132,19 @@ Componentes: - **Nota por cenário** (hoje uma `estimateNote`). Revisitar se assessores pedirem justificar só o pessimista vs. o otimista. - **Aviso soft vs `voteGoals.minimum` no cenário pessimista.** Revisitar após E8 com totais preenchidos (mesmo gatilho de A9). - **Delta semanal por cenário.** Revisitar com C12 (versions no group). +- **Abstração compartilhada de flash/pending client-side** (hoje local em `PlazaListOverview`). Revisitar quando houver o **2º** flash client-side em `/campanha` (não URL/`useTransition`). ## Referências +- Critiques: [overview](../../.impeccable/critique/2026-07-23T21-29-20Z__src-components-campaign-plazalistoverview-tsx.md), [Cenário](../../.impeccable/critique/2026-07-23T21-52-39Z__src-components-campaign-plazamappanel-tsx-cenario.md) - `docs/roadmap.md` (Trilha A / A10 → E8) - `docs/plans/estimativa-votos-praca.md` — A9 (scalar a evoluir) - `docs/plans/remodelagem-pracas.md` — assimetria declared×estimated - `docs/plans/conta-da-cadeira.md` — consumidor E8 (atualizar dep) - `src/collections/VotePledge.ts`, `src/collections/Plaza.ts` -- `src/utilities/votePledgeData.ts`, `src/utilities/plazaMapData.ts`, `src/lib/voteGoals.ts` +- `src/utilities/votePledgeData.ts`, `src/utilities/plazaMapData.ts`, `src/utilities/voteGoals.ts` - `src/components/campaign/PledgeEstimateForm.tsx`, `PlazaListExpectedVotesControl.tsx`, `PlazaMapPanel.tsx`, `DeclareVotesForm.tsx` - AGENTS.md — staff-only estimates, naming, migrations, overrideAccess - `PRODUCT.md` / `DESIGN.md` — Field Desk; princípio 5 (métricas locais) -- Feedback coordenação geral 2026-07-23 (áudio em `docs/general-coordinator-interview/`) +- Feedback coordenação geral 2026-07-23 (áudio em `docs/general-coordinator-interview/` / `CUSTOMER.md` Interview Snapshot) +- Revisão auditoria 2026-07-23: path `voteGoals` corrigido; seletor de cenário → estado local (Ano/Escala não são URL); actions reais = `estimateVotes` / `setPlazaExpectedVotes`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6c47303c..508f69ad 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-23 (D3–D5: canal WhatsApp **pessoal** por staff + QR — [plano-mestre](plans/whatsapp-interno-campanha.md); A10 cenários — [plano](plans/cenarios-estimativa-votos.md); export CSV admin entregue) +Atualizado em: 2026-07-23 (A10 cenários entregue — [plano](plans/cenarios-estimativa-votos.md); D3–D5 WhatsApp; export CSV admin entregue) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). @@ -53,7 +53,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re | ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de | | --- | ---------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | ---------------------------------------------- | -| E8 | Conta da cadeira | [detalhes](plans/conta-da-cadeira.md) | Global `campaignGoals`; potencial por praça (válidos projetados, captura do campo, roll-off, headroom); decomposição meta→praça; cobertura Σpledges÷meta (cenário default = média) | B | ~2d | 2 | **A10**, deploy remodelagem | +| E8 | Conta da cadeira | [detalhes](plans/conta-da-cadeira.md) | Global `campaignGoals`; potencial por praça (válidos projetados, captura do campo, roll-off, headroom); decomposição meta→praça; cobertura Σpledges÷meta (cenário default = média) | B | ~2d | 2 | deploy remodelagem | | E9 | Fila de alocação | [detalhes](plans/fila-de-alocacao.md) | Lista de decisão (7 colunas), ordenação por déficit descoberto + risco por frescor, "coluna da vergonha" | B | ~1,5d | 2 | E8 | | C12 | Registro-fundação | [detalhes](plans/registro-fundacao.md) | Versions em `votePledge`; sinais tipados no `plazaUpdate`; `actionPlan.origin`+custo; collection `allocationDecision` | B | ~2d | 2 | deploy remodelagem (paralelo a E8; suave: A10) | | E10 | Classificação territorial relativa | [detalhes](plans/classificacao-territorial-relativa.md) | Âncoras relativas (LQ / share da cadeira marginal), multi-eixo; substitui 35/20/10 para DF | B | ~1d | 3 | E8 | @@ -88,7 +88,7 @@ flowchart TD R2 --> A9["A9 Estimativa votos ✓"] A9 --> A9plus["A9+ Loader lista ✓"] A9plus --> B9["B9 Edição rápida lista ✓"] - A9 --> A10["A10 Cenários estimativa
pessimista/média/otimista"] + A9 --> A10["A10 Cenários estimativa ✓"] R2 --> A10 R2 -.-> B7["B7 Mapa filtrado ✓"] B7 --> B12["B12 Aproximar mapa
ao filtro ✓"] @@ -142,13 +142,13 @@ flowchart TD E11i -.decisões acumuladas.-> E15i ``` -Paralelizáveis agora: **A10** (pós-A9; paraleliza com C12 / smoke pós-deploy), fill-ins (O0+, RS+, …). **D3** (baixa/média prioridade; após remodelagem em prod + folga — não bloqueia E8/E9). ~~**A9** / **A9+** / **B9** / **B7** / **B10** / **B11** / **B6** / **B12** / **filtros-auto** / **C8** F4~~ entregues 2026-07-21. +Paralelizáveis agora: **E8** (pós-A10), fill-ins (O0+, RS+, …). **D3** (baixa/média prioridade; após remodelagem em prod + folga — não bloqueia E8/E9). ~~**A9** / **A9+** / **A10** / **B9** / **B7** / **B10** / **B11** / **B6** / **B12** / **filtros-auto** / **C8** F4~~ entregues 2026-07-21–23. ### Sequência por janela -**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos~~ / ~~**B9** edição rápida na lista~~ / ~~**B7** mapa filtrado~~ / ~~**B10** hover+click-nav~~ / ~~**B11** escala % dos válidos~~ / ~~**B12** aproximar mapa ao footprint filtrado~~ / ~~**filtros-auto** lista de Praças~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; **A10** cenários pessimista/média/otimista (pode puxar para cá pós-deploy — desbloqueia E8); R6 critique/polish; Onda 0 jurídica em paralelo. +**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos~~ / ~~**A10** cenários pessimista/média/otimista~~ / ~~**B9** edição rápida na lista~~ / ~~**B7** mapa filtrado~~ / ~~**B10** hover+click-nav~~ / ~~**B11** escala % dos válidos~~ / ~~**B12** aproximar mapa ao footprint filtrado~~ / ~~**filtros-auto** lista de Praças~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; R6 critique/polish; Onda 0 jurídica em paralelo. -**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **A10** (se não fechou na Janela 1) → **E8** conta da cadeira → **E9** fila de alocação, com **C12** registro-fundação em paralelo (migrations cedo, longe do congelamento); D2 se sobrar folga. +**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E8** conta da cadeira → **E9** fila de alocação, com **C12** registro-fundação em paralelo (migrations cedo, longe do congelamento); D2 se sobrar folga. **Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08), **E10** classificação relativa → **B13** escala relativa no mapa, **E14** níveis N0–N4, **E11** motor de sugestões v1, **E12** camada TI, **E13** planejador de giros; **B8** polígonos das Praças-zona Salvador/Camaçari (F1 catálogo de bairros shipável antes; F2 dissolve), débitos sobreviventes (abaixo). Se houver folga **e** Little Hire ainda vazar para o ZAP: começar **D3** (fundação do canal) — não compete com E8/E9 na Janela 2. @@ -156,7 +156,7 @@ Paralelizáveis agora: **A10** (pós-A9; paraleliza com C12 / smoke pós-deploy) ### Cortes seguros / não cortáveis -**Não cortáveis:** Onda 0 (jurídico/Consent); R1–R2 (sem eles a vertical não reflete a operação real); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**A9**~~ (total esperado da Praça — entregue 2026-07-21); **A10** (faixa pessimista/média/otimista — semântica operacional pedida pela coordenação; E8 consome); **E8**+**E9**+**C12** (a conta da cadeira, a fila e o registro ex-ante são o mínimo de "inteligência, não planilha" — e C12 é irrecuperável se não registrar durante a campanha). +**Não cortáveis:** Onda 0 (jurídico/Consent); R1–R2 (sem eles a vertical não reflete a operação real); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**A9**~~ (total esperado da Praça — entregue 2026-07-21); ~~**A10**~~ (faixa pessimista/média/otimista — entregue 2026-07-23); **E8**+**E9**+**C12** (a conta da cadeira, a fila e o registro ex-ante são o mínimo de "inteligência, não planilha" — e C12 é irrecuperável se não registrar durante a campanha). **Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E13** planejador de giros (agenda segue manual com J-A/J-B como guia); **E12** camada TI (rollup manual por lista); **E15** (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter fila E9 sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** (manter `priority` alta/normal); R4 mapa comparativo (manter tabela comparativa); painel de detalhe por zona no mapa; R3 organizações (manter demandas); resultado de plano com mídia (manter texto); Eleitorado/IBGE na Praça; D2 push (manter sino); A6; **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); débitos/fill-ins. ~~**B9** / **B10** / **B11** / **B6** / **B12**~~ / ~~**export CSV de assinaturas/contatos**~~ (entregues — não cortar). @@ -196,8 +196,7 @@ Paralelizáveis agora: **A10** (pós-A9; paraleliza com C12 / smoke pós-deploy) ## Próximos — sobreviventes e pós-remodelagem -- **A10** Cenários de estimativa (pessimista / média / otimista) em pledge + total da Praça + lista/mapa · liderança declara um · desbloqueia E8 · Janela 1–2 · não cortável · [plano](plans/cenarios-estimativa-votos.md) -- **Programa Inteligência de campanha** (E8–E15, B13, C12) · fatias, gaps de dados e fila canônica no [plano-mestre](plans/inteligencia-campanha.md) · embasamento: [docs/research/](research/) (relatório aprovado 2026-07-21) · **E8 depende de A10** +- **Programa Inteligência de campanha** (E8–E15, B13, C12) · fatias, gaps de dados e fila canônica no [plano-mestre](plans/inteligencia-campanha.md) · embasamento: [docs/research/](research/) (relatório aprovado 2026-07-21) · **E8** desbloqueado por A10 ✓ - **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · reenquadrar para Praça · alimenta **E13** · [plano](plans/insight-dobradinha-2026.md) - **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md) - **B8** Polígonos das Praças-zona (Salvador ZE 1–19 / Camaçari ZE 170–171): ~~F1 catálogo zona→bairros + UI na Praça~~ (entregue 2026-07-21); F2 prep catálogo (~½ dia) + dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável (F2) · [plano](plans/poligonos-pracas-zona.md) diff --git a/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx index 70b928b1..f99e5c9f 100644 --- a/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/pracas/[slug]/page.tsx @@ -12,10 +12,10 @@ import { PlazaCandidateComparisonTable } from '@/components/campaign/PlazaCandid import { PlazaLeadershipsPanel } from '@/components/campaign/PlazaLeadershipsPanel' import { PlazaPledgesPanel } from '@/components/campaign/PlazaPledgesPanel' import { PlazaStrategyCard } from '@/components/campaign/PlazaStrategyCard' -import { PlazaZoneNeighborhoodsCard } from '@/components/campaign/PlazaZoneNeighborhoodsCard' import { PlazaTabNav } from '@/components/campaign/PlazaTabNav' import { PlazaUpdateFeed } from '@/components/campaign/PlazaUpdateFeed' import { PlazaUpdateForm } from '@/components/campaign/PlazaUpdateForm' +import { PlazaZoneNeighborhoodsCard } from '@/components/campaign/PlazaZoneNeighborhoodsCard' import { RecentVisitTracker } from '@/components/campaign/RecentVisitTracker' import { Badge } from '@/components/ui/Badge' import { isCampaignStaff } from '@/utilities/campaignAccess' @@ -37,7 +37,12 @@ import { import { formatPlazaGeographyLabel, plazaKindLabels } from '@/utilities/plazaUi' import { loadPlazaUpdatesFeed, parsePlazaUpdateFeedParams } from '@/utilities/plazaUpdatePageData' import { loadAdvisorSummaries } from '@/utilities/plazaViewModels' -import { loadLeaderPledges, loadPlazaPledges, sumStaffPledgeEffectiveTotal } from '@/utilities/votePledgeData' +import { + aggregatePlazaPledgesFromRows, + loadLeaderPledges, + loadPlazaPledges, + toPlazaPledgeCoverageView, +} from '@/utilities/votePledgeData' import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions' import { createPlazaUpdateFormAction } from './updateFormActions' @@ -159,7 +164,7 @@ const OverviewTab = async ({ if (isStaffView && view.strategy) { const pledges = await loadPlazaPledges(payload, user, view.id) - const leadershipVoteTotal = sumStaffPledgeEffectiveTotal(pledges) + const pledgeCoverage = toPlazaPledgeCoverageView(aggregatePlazaPledgesFromRows(pledges)) return (

{zoneNeighborhoodsCard} @@ -167,7 +172,7 @@ const OverviewTab = async ({ strategy={view.strategy} plazaSlug={view.slug} canEdit - leadershipVoteTotal={leadershipVoteTotal} + pledgeCoverage={pledgeCoverage} />
diff --git a/src/app/(campaign)/campanha/(app)/pracas/[slug]/pledgeFormActions.ts b/src/app/(campaign)/campanha/(app)/pracas/[slug]/pledgeFormActions.ts index 047e17ac..2c9458b5 100644 --- a/src/app/(campaign)/campanha/(app)/pracas/[slug]/pledgeFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/pracas/[slug]/pledgeFormActions.ts @@ -6,9 +6,9 @@ import { declareVotes, estimateVotes } from '@/app/(campaign)/campanha/actions/v import { nullableFormText, nullableRelationshipFormValue, - optionalIntegerFormValue, requiredIntegerFormValue, requiredRelationshipFormValue, + voteEstimateScenarioFromForm, } from '@/lib/formData' import { mapCampaignFormActionError, @@ -52,15 +52,12 @@ export const estimateVotesFormAction = async ( ): Promise => { try { const pledge = requiredRelationshipFormValue(formData, 'pledgeId') - const estimatedVotes = optionalIntegerFormValue(formData, 'estimatedVotes', { - minimum: 0, - maximum: 1_000_000, - }) + const estimatedVotes = voteEstimateScenarioFromForm(formData, 'estimatedVotes') const estimateNote = nullableFormText(formData, 'estimateNote') await estimateVotes({ pledge, - estimatedVotes: estimatedVotes ?? null, + estimatedVotes, estimateNote, }) revalidatePath('/campanha/pracas/[slug]', 'page') diff --git a/src/app/(campaign)/campanha/(app)/pracas/expected-votes/route.ts b/src/app/(campaign)/campanha/(app)/pracas/expected-votes/route.ts new file mode 100644 index 00000000..9bdb877e --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pracas/expected-votes/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { plazaStaffEditSafeMessages } from '@/app/(campaign)/campanha/(app)/pracas/plazaStaffEditMessages' +import { setPlazaExpectedVotes } from '@/app/(campaign)/campanha/actions/plaza' +import { positiveRelationshipId } from '@/lib/schemas/primitives' +import { + CAMPAIGN_SESSION_EXPIRED_MESSAGE, + mapCampaignFormActionError, +} from '@/utilities/campaignFormActionError' +import { toVoteEstimateScenarioViewModel } from '@/utilities/voteEstimate' + +import type { PlazaListExpectedVotesResponse } from './types' + +export type { PlazaListExpectedVotesResponse } from './types' + +export const dynamic = 'force-dynamic' + +const optionalEstimate = z.number().int().min(0).max(1_000_000).nullable() + +/** Unordered draft body — setPlazaExpectedVotes normalizes before persist. */ +const bodySchema = z.object({ + plazaId: positiveRelationshipId, + expectedVotes: z.object({ + pessimistic: optionalEstimate, + central: optionalEstimate, + optimistic: optionalEstimate, + }), +}) + +const isSameOriginRequest = (request: Request): boolean => { + const origin = request.headers.get('origin') + if (!origin) return true + try { + return new URL(origin).origin === new URL(request.url).origin + } catch { + return false + } +} + +export async function POST( + request: Request, +): Promise> { + if (!isSameOriginRequest(request)) { + return NextResponse.json({ status: 'error', message: 'Requisição inválida.' }, { status: 403 }) + } + + let json: unknown + try { + json = await request.json() + } catch { + return NextResponse.json( + { status: 'error', message: 'Corpo da requisição inválido.' }, + { status: 400 }, + ) + } + + try { + const { plazaId, expectedVotes } = bodySchema.parse(json) + const updated = await setPlazaExpectedVotes({ plaza: plazaId, expectedVotes }) + + return NextResponse.json({ + status: 'success', + message: 'Votos estimados atualizados.', + savedExpectedVotes: toVoteEstimateScenarioViewModel(updated.expectedVotes), + }) + } catch (error) { + const mapped = mapCampaignFormActionError({ + error, + safeMessages: [ + ...plazaStaffEditSafeMessages, + 'Autenticação necessária.', + CAMPAIGN_SESSION_EXPIRED_MESSAGE, + ], + genericMessage: + 'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.', + }) + + const isAuthError = + error instanceof Error && + (error.message === 'Autenticação necessária.' || + error.message === CAMPAIGN_SESSION_EXPIRED_MESSAGE) + + return NextResponse.json( + { + status: 'error', + message: mapped.message ?? 'Não foi possível salvar os votos estimados.', + }, + { status: isAuthError ? 401 : 400 }, + ) + } +} diff --git a/src/app/(campaign)/campanha/(app)/pracas/expected-votes/types.ts b/src/app/(campaign)/campanha/(app)/pracas/expected-votes/types.ts new file mode 100644 index 00000000..72368fc7 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pracas/expected-votes/types.ts @@ -0,0 +1,12 @@ +import type { VoteEstimateScenarioViewModel } from '@/utilities/voteEstimate' + +export type PlazaListExpectedVotesResponse = + | { + status: 'success' + message: string + savedExpectedVotes: VoteEstimateScenarioViewModel + } + | { + status: 'error' + message: string + } diff --git a/src/app/(campaign)/campanha/(app)/pracas/page.tsx b/src/app/(campaign)/campanha/(app)/pracas/page.tsx index 5a75c291..bdfb128e 100644 --- a/src/app/(campaign)/campanha/(app)/pracas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/pracas/page.tsx @@ -7,6 +7,7 @@ import { getPayload } from 'payload' import { CampaignListPagination } from '@/components/campaign/CampaignListPagination' import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' +import { PlazaEstimateScenarioProvider } from '@/components/campaign/PlazaEstimateScenarioContext' import { PlazaFilters } from '@/components/campaign/PlazaFilters' import { PlazaList } from '@/components/campaign/PlazaList' import { PlazaListOverview } from '@/components/campaign/PlazaListOverview' @@ -33,10 +34,9 @@ import { getCampaignScopeLabel, resolvePlazaListUrl, } from '@/utilities/plazaUi' -import { loadAdvisorSummaries, getEligibleAdvisorOptions } from '@/utilities/plazaViewModels' +import { getEligibleAdvisorOptions, loadAdvisorSummaries } from '@/utilities/plazaViewModels' import { assignPlazaAdvisorsFormAction, - setPlazaExpectedVotesFormAction, setPlazaPoliticalTrendFormAction, } from './plazaStaffFormActions' @@ -59,8 +59,7 @@ export default async function PlazasPage({ searchParams }: PlazasPageProps) { const candidateOptions = pageBundle.mapBundle ? await loadFederalCandidateOptions(payload, user) : [] - const { plazas: listPlazas, totalDocs, totalPages, scopeTotal, overview, mapBundle } = - pageBundle + const { plazas: listPlazas, totalDocs, totalPages, scopeTotal, overview, mapBundle } = pageBundle const resolvedUrl = resolvePlazaListUrl(rawSearchParams, totalPages) if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref) const { state } = resolvedUrl @@ -69,8 +68,68 @@ export default async function PlazasPage({ searchParams }: PlazasPageProps) { const advisorIDs = [...new Set(listPlazas.flatMap((plaza) => plaza.advisorIDs))] const advisorSummaries = isStaffView ? await loadAdvisorSummaries(payload, user, advisorIDs) : [] const advisorNamesById = new Map(advisorSummaries.map((advisor) => [advisor.id, advisor])) - const advisorOptions = - isCoordinator ? await getEligibleAdvisorOptions(payload, user) : [] + const advisorOptions = isCoordinator ? await getEligibleAdvisorOptions(payload, user) : [] + + const filters = ( + + ) + + const listBody = listPlazas.length ? ( + <> + {isStaffView && overview ? : null} + +
+

+ {totalDocs} {totalDocs === 1 ? 'Praça encontrada' : 'Praças encontradas'} +

+ buildPlazaListHref(state, page)} + /> +
+ + ) : ( + + + + + Nenhuma Praça encontrada + + Ajuste a busca ou os filtros. Você só vê Praças dentro do seu escopo. + + + + + + + ) + + const main = isStaffView ? ( + + {mapBundle ? ( + + ) : null} + {filters} + {listBody} + + ) : ( + <> + {filters} + {listBody} + + ) return ( @@ -80,64 +139,10 @@ export default async function PlazasPage({ searchParams }: PlazasPageProps) { As 436 Praças da campanha: um município cada — em Salvador e Camaçari, uma zona eleitoral cada.

- - {getCampaignScopeLabel(user.role, scopeTotal)} - + {getCampaignScopeLabel(user.role, scopeTotal)} - {mapBundle ? ( - - ) : null} - - - - {listPlazas.length ? ( - <> - {overview ? : null} - -
-

- {totalDocs}{' '} - {totalDocs === 1 ? 'Praça encontrada' : 'Praças encontradas'} -

- buildPlazaListHref(state, page)} - /> -
- - ) : ( - - - - - Nenhuma Praça encontrada - - Ajuste a busca ou os filtros. Você só vê Praças dentro do seu escopo. - - - - - - - )} + {main} {listVisitLabel ? ( => { try { const plaza = requiredRelationshipFormValue(formData, 'plazaId') - const expectedVotes = - optionalIntegerFormValue(formData, 'expectedVotes', { - minimum: 0, - maximum: 1_000_000, - }) ?? null + const expectedVotes = voteEstimateScenarioFromForm(formData, 'expectedVotes') await setPlazaExpectedVotes({ plaza, expectedVotes }) revalidatePlazaListPaths({ slug: optionalPlazaSlugFromForm(formData) }) diff --git a/src/app/(campaign)/campanha/actions/plaza.ts b/src/app/(campaign)/campanha/actions/plaza.ts index 346d2e28..b41d4113 100644 --- a/src/app/(campaign)/campanha/actions/plaza.ts +++ b/src/app/(campaign)/campanha/actions/plaza.ts @@ -17,6 +17,10 @@ import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campa import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction' import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' +import { + normalizeVoteEstimateOnSave, + toVoteEstimateScenarioViewModel, +} from '@/utilities/voteEstimate' const getFreshStaffActor = async ( payload: Payload, @@ -92,7 +96,13 @@ export const setPlazaExpectedVotesRecord = async ( actor: CampaignUser, input: PlazaExpectedVotesInput, ) => { - const { plaza, expectedVotes } = plazaExpectedVotesSchema.parse(input) + const normalizedExpectedVotes = normalizeVoteEstimateOnSave( + toVoteEstimateScenarioViewModel(input.expectedVotes), + ) + const { plaza, expectedVotes } = plazaExpectedVotesSchema.parse({ + plaza: input.plaza, + expectedVotes: normalizedExpectedVotes, + }) const currentActor = await getFreshStaffActor(payload, actor) return payload.update({ diff --git a/src/app/(campaign)/campanha/actions/votePledge.ts b/src/app/(campaign)/campanha/actions/votePledge.ts index 9e2fca06..f09ddf16 100644 --- a/src/app/(campaign)/campanha/actions/votePledge.ts +++ b/src/app/(campaign)/campanha/actions/votePledge.ts @@ -13,6 +13,10 @@ import { getCampaignActionContext, reloadCampaignActor } from '@/utilities/campa import { withPayloadTransaction } from '@/utilities/payloadTransaction' import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks' import { relationshipId, requireRelationshipId } from '@/utilities/relationship' +import { + normalizeVoteEstimateOnSave, + toVoteEstimateScenarioViewModel, +} from '@/utilities/voteEstimate' const OWN_ENGAGED_LEADERSHIP_REQUIRED = 'Somente lideranças engajadas podem declarar votos.' const PLAZA_NOT_LINKED = 'A liderança precisa estar vinculada à Praça para declarar votos nela.' @@ -125,7 +129,12 @@ export const estimateVotesRecord = async ( actor: CampaignUser, input: EstimateVotesInput, ) => { - const data = estimateVotesSchema.parse(input) + const data = estimateVotesSchema.parse({ + ...input, + estimatedVotes: normalizeVoteEstimateOnSave( + toVoteEstimateScenarioViewModel(input.estimatedVotes), + ), + }) return withPayloadTransaction( payload, diff --git a/src/collections/Plaza.ts b/src/collections/Plaza.ts index dc53935b..fb6ccacd 100644 --- a/src/collections/Plaza.ts +++ b/src/collections/Plaza.ts @@ -18,6 +18,15 @@ import { eligibleCampaignStaffWhere, } from '@/utilities/campaignAccess' import { relationshipId } from '@/utilities/relationship' +import { + getVoteEstimateOrderViolation, + VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, + type VoteEstimateScenarioFields, +} from '@/utilities/voteEstimate' +import { + voteEstimateScenarioGroupAccess, + voteEstimateScenarioGroupFields, +} from '@/utilities/voteEstimateScenarioFields' import { getVoteGoalsOrderViolation, VOTE_GOALS_ORDER_ERROR_MESSAGE, @@ -72,6 +81,46 @@ const validateVoteGoals: CollectionBeforeValidateHook = ({ data, originalDoc, op return data } +const validateExpectedVotes: CollectionBeforeValidateHook = ({ data, originalDoc, operation }) => { + if (!data) return data + + const mergedExpected = + data.expectedVotes !== undefined + ? { ...(originalDoc?.expectedVotes ?? {}), ...(data.expectedVotes as object) } + : operation === 'update' + ? originalDoc?.expectedVotes + : undefined + + if (!mergedExpected || typeof mergedExpected !== 'object') return data + + const pessimistic = voteGoalNumber((mergedExpected as VoteEstimateScenarioFields).pessimistic) + const central = voteGoalNumber((mergedExpected as VoteEstimateScenarioFields).central) + const optimistic = voteGoalNumber((mergedExpected as VoteEstimateScenarioFields).optimistic) + + if (getVoteEstimateOrderViolation({ pessimistic, central, optimistic })) { + throw new APIError(VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, 400) + } + + if (data.expectedVotes && typeof data.expectedVotes === 'object') { + data.expectedVotes = { + pessimistic: + (data.expectedVotes as { pessimistic?: unknown }).pessimistic === undefined + ? (originalDoc?.expectedVotes?.pessimistic ?? null) + : pessimistic, + central: + (data.expectedVotes as { central?: unknown }).central === undefined + ? (originalDoc?.expectedVotes?.central ?? null) + : central, + optimistic: + (data.expectedVotes as { optimistic?: unknown }).optimistic === undefined + ? (originalDoc?.expectedVotes?.optimistic ?? null) + : optimistic, + } + } + + return data +} + const validatePlazaAdvisors: CollectionBeforeValidateHook = async ({ data, req }) => { if (!data || data.advisors === undefined) return data @@ -142,7 +191,7 @@ export const Plaza: CollectionConfig = { delete: canDeletePlaza, }, hooks: { - beforeValidate: [validatePlazaAdvisors, validateVoteGoals], + beforeValidate: [validatePlazaAdvisors, validateVoteGoals, validateExpectedVotes], beforeChange: [derivePoliticalTrendAudit], }, fields: [ @@ -326,18 +375,14 @@ export const Plaza: CollectionConfig = { }, { name: 'expectedVotes', - type: 'number', + type: 'group', label: 'Votos estimados', - min: 0, - access: { - read: canReadCampaignStaffField, - create: canManageCampaignStaffField, - update: canManageCampaignStaffField, - }, + access: voteEstimateScenarioGroupAccess, admin: { description: - 'Total esperado da Praça — distinto das metas de cenário e da soma das lideranças.', + 'Total esperado da Praça por cenário — distinto das metas de planejamento e da soma das lideranças.', }, + fields: voteEstimateScenarioGroupFields(), }, { name: 'politicalTrend', diff --git a/src/collections/VotePledge.ts b/src/collections/VotePledge.ts index 2aaf4e82..d1e6be32 100644 --- a/src/collections/VotePledge.ts +++ b/src/collections/VotePledge.ts @@ -11,15 +11,49 @@ import { canUpdateVotePledge, } from '@/utilities/campaignAccess' import { relationshipId } from '@/utilities/relationship' +import { + type VoteEstimateScenarioFields, + getVoteEstimateOrderViolation, + VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, +} from '@/utilities/voteEstimate' +import { + voteEstimateScenarioGroupAccess, + voteEstimateScenarioGroupFields, +} from '@/utilities/voteEstimateScenarioFields' /** * Vote pledge per leadership × plaza. The leader DECLARES how many votes they * are bringing (`declaredVotes` — the only number they ever see); staff record - * their own ESTIMATE of the real value (`estimatedVotes`), which is never - * serialized to the leader (field access denies read). Plaza aggregates use - * `estimatedVotes ?? declaredVotes` on staff surfaces only. + * their own ESTIMATE of the real value (`estimatedVotes` pessimistic/central/ + * optimistic), which is never serialized to the leader (field access denies + * read). Plaza aggregates use `estimated[S] ?? declared` on staff surfaces only. */ +const normalizeEstimateGroup = ( + value: VoteEstimateScenarioFields | number | null | undefined, +): VoteEstimateScenarioFields | null | undefined => { + if (value === undefined) return undefined + if (value === null) return null + if (typeof value === 'number') { + return { central: value, pessimistic: null, optimistic: null } + } + return value +} + +const estimateGroupChanged = ( + next: VoteEstimateScenarioFields | null | undefined, + previous: VoteEstimateScenarioFields | null | undefined, +): boolean => { + if (next === undefined) return false + const normalizedNext = normalizeEstimateGroup(next) ?? {} + const normalizedPrevious = normalizeEstimateGroup(previous) ?? {} + return ( + normalizedNext.pessimistic !== normalizedPrevious.pessimistic || + normalizedNext.central !== normalizedPrevious.central || + normalizedNext.optimistic !== normalizedPrevious.optimistic + ) +} + const validatePledgeIntegrity: CollectionBeforeChangeHook = async ({ data, operation, @@ -70,7 +104,10 @@ const deriveVotePledgeAudit: CollectionBeforeChangeHook = ({ data, originalDoc, } const estimateChanged = - (data.estimatedVotes !== undefined && data.estimatedVotes !== originalDoc?.estimatedVotes) || + estimateGroupChanged( + data.estimatedVotes as VoteEstimateScenarioFields | undefined, + originalDoc?.estimatedVotes as VoteEstimateScenarioFields | null | undefined, + ) || (data.estimateNote !== undefined && data.estimateNote !== originalDoc?.estimateNote) if (estimateChanged) { @@ -81,6 +118,16 @@ const deriveVotePledgeAudit: CollectionBeforeChangeHook = ({ data, originalDoc, return data } +const validateEstimateOrder: CollectionBeforeChangeHook = ({ data }) => { + const violation = getVoteEstimateOrderViolation( + data.estimatedVotes as VoteEstimateScenarioFields | null | undefined, + ) + if (violation) { + throw new APIError(VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, 400) + } + return data +} + export const VotePledge: CollectionConfig = { slug: 'votePledge', labels: { @@ -90,7 +137,7 @@ export const VotePledge: CollectionConfig = { admin: { group: 'Campanha', useAsTitle: 'id', - defaultColumns: ['leadership', 'plaza', 'declaredVotes', 'estimatedVotes', 'updatedAt'], + defaultColumns: ['leadership', 'plaza', 'declaredVotes', 'estimatedVotes.central', 'updatedAt'], }, access: { create: canCreateVotePledge, @@ -105,7 +152,7 @@ export const VotePledge: CollectionConfig = { }, ], hooks: { - beforeChange: [validatePledgeIntegrity, deriveVotePledgeAudit], + beforeChange: [validatePledgeIntegrity, validateEstimateOrder, deriveVotePledgeAudit], }, fields: [ { @@ -159,18 +206,14 @@ export const VotePledge: CollectionConfig = { }, { name: 'estimatedVotes', - type: 'number', + type: 'group', label: 'Votos estimados pelo assessor', - min: 0, - index: true, admin: { - description: 'Estimativa interna do valor real. A liderança nunca vê este número.', - }, - access: { - create: canManageCampaignStaffField, - read: canReadCampaignStaffField, - update: canManageCampaignStaffField, + description: + 'Faixa interna pessimista / média / otimista. A liderança nunca vê estes números.', }, + access: voteEstimateScenarioGroupAccess, + fields: voteEstimateScenarioGroupFields(), }, { name: 'estimateNote', diff --git a/src/components/campaign/CampaignDashboard.tsx b/src/components/campaign/CampaignDashboard.tsx index 9cc9bfb1..dcc8b98d 100644 --- a/src/components/campaign/CampaignDashboard.tsx +++ b/src/components/campaign/CampaignDashboard.tsx @@ -9,9 +9,13 @@ import { import { RecentlyVisitedCard } from '@/components/campaign/RecentlyVisitedCard' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' +import { formatElectionNumber } from '@/lib/electionInsights' import type { CampaignDashboardView } from '@/utilities/campaignDashboardData' +import { + formatVoteEstimateEndpointsLabel, + voteEstimateScenarioLabels, +} from '@/utilities/voteEstimate' -const voteFormatter = new Intl.NumberFormat('pt-BR') const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short', @@ -45,7 +49,7 @@ export const CampaignDashboard = ({

{plaza.declaredVotes == null ? 'Você ainda não declarou votos nesta Praça.' - : `Você declarou ${voteFormatter.format(plaza.declaredVotes)} votos.`} + : `Você declarou ${formatElectionNumber(plaza.declaredVotes)} votos.`}

+ + + {children} + + +) diff --git a/src/components/campaign/CampaignMetricStrip.tsx b/src/components/campaign/CampaignMetricStrip.tsx index 99a5a197..7e87c9f6 100644 --- a/src/components/campaign/CampaignMetricStrip.tsx +++ b/src/components/campaign/CampaignMetricStrip.tsx @@ -1,11 +1,15 @@ +import type { ReactNode } from 'react' + import { Progress } from '@/components/ui/Progress' import { cn } from '@/lib/utils' export type CampaignMetric = { label: string value: string + detail?: ReactNode emphasize?: boolean progress?: number + valueAriaLabel?: string } export const CampaignMetricStrip = ({ @@ -37,9 +41,17 @@ export const CampaignMetricStrip = ({ ? 'text-2xl font-semibold text-foreground' : 'text-lg font-medium text-foreground', )} + aria-label={metric.valueAriaLabel} > {metric.value} + {metric.detail ? ( + typeof metric.detail === 'string' ? ( +

{metric.detail}

+ ) : ( +
{metric.detail}
+ ) + ) : null} {metric.progress !== undefined ? ( void +} + +const PlazaEstimateScenarioContext = createContext(null) + +export const PlazaEstimateScenarioProvider = ({ children }: { children: ReactNode }) => { + const [scenario, setScenario] = useState(DEFAULT_VOTE_ESTIMATE_SCENARIO) + const value = useMemo(() => ({ scenario, setScenario }), [scenario]) + + return ( + + {children} + + ) +} + +export const usePlazaEstimateScenario = (): PlazaEstimateScenarioContextValue => { + const value = useContext(PlazaEstimateScenarioContext) + if (!value) { + throw new Error('usePlazaEstimateScenario must be used within PlazaEstimateScenarioProvider') + } + return value +} + +export const usePlazaEstimateScenarioOptional = (): PlazaEstimateScenarioContextValue | null => + useContext(PlazaEstimateScenarioContext) diff --git a/src/components/campaign/PlazaList.tsx b/src/components/campaign/PlazaList.tsx index 5dbac62b..bfbd14e3 100644 --- a/src/components/campaign/PlazaList.tsx +++ b/src/components/campaign/PlazaList.tsx @@ -1,8 +1,8 @@ import { CircleAlertIcon, CircleCheckIcon } from 'lucide-react' import Link from 'next/link' -import { PlazaListAdvisorsControl } from '@/components/campaign/PlazaListAdvisorsControl' import { PlazaAdvisorAvatarStack } from '@/components/campaign/PlazaAdvisorAvatarStack' +import { PlazaListAdvisorsControl } from '@/components/campaign/PlazaListAdvisorsControl' import { PlazaListExpectedVotesControl } from '@/components/campaign/PlazaListExpectedVotesControl' import { PlazaListTrendControl } from '@/components/campaign/PlazaListTrendControl' import { Badge } from '@/components/ui/Badge' @@ -26,6 +26,7 @@ import type { PlazaAdvisorSummary, PlazaListViewModel, } from '@/utilities/plazaViewModels' +import { toPlazaPledgeCoverageView } from '@/utilities/votePledgeData' const dateFormatter = new Intl.DateTimeFormat('pt-BR') @@ -40,7 +41,6 @@ export type PlazaListProps = { isStaffView: boolean isCoordinator: boolean advisorOptions: EligibleAdvisorOption[] - expectedVotesFormAction: PlazaStaffFormAction trendFormAction: PlazaStaffFormAction advisorsFormAction: PlazaStaffFormAction } @@ -51,7 +51,6 @@ export const PlazaList = ({ isStaffView, isCoordinator, advisorOptions, - expectedVotesFormAction, trendFormAction, advisorsFormAction, }: PlazaListProps) => { @@ -86,13 +85,11 @@ export const PlazaList = ({
Votos estimados
-
+
@@ -148,7 +145,7 @@ export const PlazaList = ({ <> Assessores Tendência - Votos estimados + Votos estimados Última atualização Cobertura @@ -206,14 +203,14 @@ export const PlazaList = ({ formAction={trendFormAction} /> - - + +
+ +
{plaza.lastUpdateAt diff --git a/src/components/campaign/PlazaListExpectedVotesControl.tsx b/src/components/campaign/PlazaListExpectedVotesControl.tsx index b6bd5159..0eb1e5ad 100644 --- a/src/components/campaign/PlazaListExpectedVotesControl.tsx +++ b/src/components/campaign/PlazaListExpectedVotesControl.tsx @@ -1,92 +1,207 @@ 'use client' -import { useActionState, useEffect, useState } from 'react' -import { toast } from 'sonner' +import { useEffect, useRef, useState } from 'react' +import type { PlazaListExpectedVotesResponse } from '@/app/(campaign)/campanha/(app)/pracas/expected-votes/types' +import { usePlazaEstimateScenarioOptional } from '@/components/campaign/PlazaEstimateScenarioContext' +import { StaffPlazaVotesDisplay } from '@/components/campaign/StaffPlazaVotesDisplay' +import { VoteEstimateScenarioInputs } from '@/components/campaign/VoteEstimateScenarioInputs' import { Alert, AlertDescription } from '@/components/ui/Alert' -import { Button } from '@/components/ui/button' -import { Field, FieldLabel } from '@/components/ui/field' -import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' import { Spinner } from '@/components/ui/Spinner' -import { StaffPlazaVotesDisplay } from '@/components/campaign/StaffPlazaVotesDisplay' -import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' +import { cn } from '@/lib/utils' +import { + DEFAULT_VOTE_ESTIMATE_SCENARIO, + voteEstimatesEqual, + type VoteEstimateScenarioViewModel, +} from '@/utilities/voteEstimate' +import type { PlazaPledgeCoverageView } from '@/utilities/votePledgeData' + +const AUTOSAVE_MS = 600 +const EXPECTED_VOTES_ENDPOINT = '/campanha/pracas/expected-votes' +const SAVE_ERROR_MESSAGE = 'Não foi possível salvar os votos estimados. Tente novamente.' type PlazaListExpectedVotesControlProps = { plazaID: number - plazaSlug: string - expectedVotes: number | null - leadershipEffectiveTotal: number - formAction: ( - state: CampaignFormActionState, - formData: FormData, - ) => Promise + expectedVotes: VoteEstimateScenarioViewModel + pledgeCoverage: PlazaPledgeCoverageView | null } export const PlazaListExpectedVotesControl = ({ plazaID, - plazaSlug, expectedVotes, - leadershipEffectiveTotal, - formAction, + pledgeCoverage, }: PlazaListExpectedVotesControlProps) => { + const scenarioContext = usePlazaEstimateScenarioOptional() + const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO const [open, setOpen] = useState(false) - const [state, submitAction, isPending] = useActionState(formAction, {}) + const [displayVotes, setDisplayVotes] = useState(expectedVotes) + const [draft, setDraft] = useState(expectedVotes) + const [isDirty, setIsDirty] = useState(false) + const [isPending, setIsPending] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + const saveTimeoutRef = useRef | undefined>(undefined) + const abortRef = useRef(null) + const committedVotesRef = useRef(expectedVotes) + const lastPropsVotesRef = useRef(expectedVotes) + const saveGenerationRef = useRef(0) + // Adopt server props only when they change from outside (navigation / RSC refresh). useEffect(() => { - if (state.status !== 'success') return - toast.success(state.message) - setOpen(false) - }, [state.message, state.status]) + if (voteEstimatesEqual(expectedVotes, lastPropsVotesRef.current)) return + lastPropsVotesRef.current = expectedVotes + committedVotesRef.current = expectedVotes + setDisplayVotes(expectedVotes) + setDraft(expectedVotes) + setIsDirty(false) + }, [expectedVotes]) + + useEffect( + () => () => { + clearTimeout(saveTimeoutRef.current) + abortRef.current?.abort() + }, + [], + ) + + const saveDraft = async (values: VoteEstimateScenarioViewModel) => { + if (voteEstimatesEqual(values, committedVotesRef.current)) return + + const generation = ++saveGenerationRef.current + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + + setIsPending(true) + setErrorMessage(null) + + try { + const response = await fetch(EXPECTED_VOTES_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + credentials: 'same-origin', + signal: controller.signal, + body: JSON.stringify({ plazaId: plazaID, expectedVotes: values }), + }) + + const payload = (await response.json()) as PlazaListExpectedVotesResponse + + if (generation !== saveGenerationRef.current) return + + if (!response.ok || payload.status !== 'success') { + setDisplayVotes(committedVotesRef.current) + setDraft(committedVotesRef.current) + setErrorMessage(payload.status === 'error' ? payload.message : SAVE_ERROR_MESSAGE) + return + } + + committedVotesRef.current = payload.savedExpectedVotes + setDisplayVotes(payload.savedExpectedVotes) + setDraft(payload.savedExpectedVotes) + setIsDirty(false) + } catch { + if (controller.signal.aborted || generation !== saveGenerationRef.current) return + setDisplayVotes(committedVotesRef.current) + setDraft(committedVotesRef.current) + setErrorMessage(SAVE_ERROR_MESSAGE) + } finally { + if (generation === saveGenerationRef.current) { + setIsPending(false) + } + } + } + + const scheduleSave = (values: VoteEstimateScenarioViewModel) => { + clearTimeout(saveTimeoutRef.current) + saveTimeoutRef.current = setTimeout(() => { + void saveDraft(values) + }, AUTOSAVE_MS) + } + + const handleValuesChange = (values: VoteEstimateScenarioViewModel) => { + setIsDirty(true) + setDraft(values) + setDisplayVotes(values) + scheduleSave(values) + } + + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) { + setIsDirty(false) + setErrorMessage(null) + setDraft(displayVotes) + } else if (open) { + clearTimeout(saveTimeoutRef.current) + if (!voteEstimatesEqual(draft, committedVotesRef.current)) { + void saveDraft(draft) + } + setIsDirty(false) + } + setOpen(nextOpen) + } + + const statusMessage = errorMessage + ? errorMessage + : isPending + ? 'Salvando votos estimados.' + : isDirty + ? 'Alterações serão salvas automaticamente.' + : '' return ( - + - -
- - - - Total da Praça - { - if (event.key === 'Enter') { - event.preventDefault() - event.currentTarget.form?.requestSubmit() - } - }} + event.preventDefault()} + > +
+ {isPending ? ( + - - {state.message && state.status !== 'success' ? ( - - {state.message} + ) : null} + + {errorMessage ? ( + + {errorMessage} ) : null} - - +

+ {statusMessage} +

+
) diff --git a/src/components/campaign/PlazaListOverview.tsx b/src/components/campaign/PlazaListOverview.tsx index efcaefb0..1212f076 100644 --- a/src/components/campaign/PlazaListOverview.tsx +++ b/src/components/campaign/PlazaListOverview.tsx @@ -1,40 +1,124 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + import { CampaignMetricStrip } from '@/components/campaign/CampaignMetricStrip' import { campaignPrioritySurfaceClassName } from '@/components/campaign/CampaignPageShell' +import { usePlazaEstimateScenario } from '@/components/campaign/PlazaEstimateScenarioContext' +import { + VOTE_ESTIMATE_SCENARIO_OVERVIEW_HINT, + VoteEstimateScenarioField, +} from '@/components/campaign/VoteEstimateScenarioField' +import { VoteEstimateScenarioStrip } from '@/components/campaign/VoteEstimateScenarioStrip' +import { formatElectionNumber } from '@/lib/electionInsights' +import { cn } from '@/lib/utils' import type { PlazaListOverviewData } from '@/utilities/plazaPageData' +import { + formatVoteEstimateEndpointsLabel, + hasAnyVoteEstimate, + voteEstimateScenarioLabels, +} from '@/utilities/voteEstimate' -const voteFormatter = new Intl.NumberFormat('pt-BR') +const SCENARIO_FLASH_MS = 420 -export const PlazaListOverview = ({ view }: { view: PlazaListOverviewData }) => ( -
- 0 ? voteFormatter.format(view.staffVoteTotal) : '—', - emphasize: true, - }, - { - label: 'Declarações de votos', - value: view.pledgeCount - ? `${voteFormatter.format(view.pledgeCount)}${ - view.missingEstimateCount - ? ` · ${voteFormatter.format(view.missingEstimateCount)} sem estimativa` - : '' - }` - : 'Nenhuma', - }, - { - label: 'Cobertura de assessoria', - value: `${view.withAdvisorCount} de ${view.plazaCount} Praças`, - progress: - view.plazaCount > 0 - ? Math.round((view.withAdvisorCount / view.plazaCount) * 100) - : undefined, - }, - ]} - /> -
-) +export const PlazaListOverview = ({ view }: { view: PlazaListOverviewData }) => { + const { scenario, setScenario } = usePlazaEstimateScenario() + const [isFlashing, setIsFlashing] = useState(false) + const skipFlashRef = useRef(true) + const flashTimeoutRef = useRef | undefined>(undefined) + + useEffect(() => { + if (skipFlashRef.current) { + skipFlashRef.current = false + return + } + const reduceMotion = + typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches + if (reduceMotion) return + setIsFlashing(true) + clearTimeout(flashTimeoutRef.current) + flashTimeoutRef.current = setTimeout(() => setIsFlashing(false), SCENARIO_FLASH_MS) + return () => clearTimeout(flashTimeoutRef.current) + }, [scenario]) + + const scenarioTotals = view.staffVoteTotalByScenario + const activeTotal = scenarioTotals[scenario] + const endpointsLabel = formatVoteEstimateEndpointsLabel(scenarioTotals) + const scenarioLabel = voteEstimateScenarioLabels[scenario] + const hasScenarioTotals = hasAnyVoteEstimate(scenarioTotals) + + const heroDetail = ( +
+ {hasScenarioTotals ? ( + + ) : null} + {endpointsLabel ? ( +

{endpointsLabel}

+ ) : null} + {view.missingEstimateCount > 0 ? ( +

+ {formatElectionNumber(view.missingEstimateCount)}{' '} + {view.missingEstimateCount === 1 ? 'declaração' : 'declarações'} sem estimativa +

+ ) : null} +
+ ) + + return ( +
+
+ +
+ 0 + ? `; ${formatElectionNumber(view.missingEstimateCount)} sem estimativa` + : '' + }`, + }, + { + label: 'Declarações de votos', + value: view.pledgeCount ? formatElectionNumber(view.pledgeCount) : 'Nenhuma', + }, + { + label: 'Cobertura de assessoria', + value: `${view.withAdvisorCount} de ${view.plazaCount} Praças`, + progress: + view.plazaCount > 0 + ? Math.round((view.withAdvisorCount / view.plazaCount) * 100) + : undefined, + }, + ]} + /> +
+ ) +} diff --git a/src/components/campaign/PlazaMapPanel.tsx b/src/components/campaign/PlazaMapPanel.tsx index b47f5648..2fde15ff 100644 --- a/src/components/campaign/PlazaMapPanel.tsx +++ b/src/components/campaign/PlazaMapPanel.tsx @@ -7,13 +7,15 @@ import { useCallback, useMemo, useRef, useState } from 'react' import { BahiaMap, type BahiaMapFeatureInfo } from '@/components/campaign/BahiaMap' import { ChoroplethLegend } from '@/components/campaign/ChoroplethLegend' import { MapFeatureReadout } from '@/components/campaign/MapFeatureReadout' +import { usePlazaEstimateScenarioOptional } from '@/components/campaign/PlazaEstimateScenarioContext' +import { + VOTE_ESTIMATE_SCENARIO_MAP_HINT, + VoteEstimateScenarioField, +} from '@/components/campaign/VoteEstimateScenarioField' import { Field, FieldLabel } from '@/components/ui/field' import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' import { computeChoroplethMax } from '@/lib/bahiaMapStyle' -import { - computeValidVoteShares, - divergingGradientCss, -} from '@/lib/choroplethColorScale' +import { computeValidVoteShares, divergingGradientCss } from '@/lib/choroplethColorScale' import { formatElectionNumber } from '@/lib/electionInsights' import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions' import { @@ -26,6 +28,11 @@ import { type PlazaMapYear, } from '@/utilities/plazaMapData' import { resolvePlazaMapNavigation } from '@/utilities/plazaMapNavigation' +import { + DEFAULT_VOTE_ESTIMATE_SCENARIO, + voteEstimateScenarioLabels, + type VoteEstimateScenario, +} from '@/utilities/voteEstimate' type PlazaMapPanelProps = { bundle: PlazaMapBundle @@ -43,6 +50,12 @@ export const PlazaMapPanel = ({ const searchParams = useSearchParams() const [year, setYear] = useState(defaultYear) const [scaleMode, setScaleMode] = useState('percentValid') + const scenarioContext = usePlazaEstimateScenarioOptional() + const [localEstimateScenario, setLocalEstimateScenario] = useState( + DEFAULT_VOTE_ESTIMATE_SCENARIO, + ) + const estimateScenario = scenarioContext?.scenario ?? localEstimateScenario + const setEstimateScenario = scenarioContext?.setScenario ?? setLocalEstimateScenario const [selectedFeature, setSelectedFeature] = useState(null) const selectedKeyRef = useRef(null) @@ -54,8 +67,11 @@ export const PlazaMapPanel = ({ if (comparisonActive && comparison) { return comparison.diffByYear[String(year)] ?? {} } + if (year === 2026) { + return bundle.values2026ByScenario[estimateScenario] ?? {} + } return bundle.valuesByYear[String(year)] ?? {} - }, [bundle, comparison, comparisonActive, year]) + }, [bundle, comparison, comparisonActive, estimateScenario, year]) const validVotesForYear = bundle.validVotesByYear[String(year)] ?? {} @@ -68,17 +84,10 @@ export const PlazaMapPanel = ({ const displayMax = useMemo(() => { const fillMode = comparisonActive ? 'diverging' : 'sequential' - return computeChoroplethMax( - displayValues, - fillMode, - percentScaleActive ? 1 : undefined, - ) + return computeChoroplethMax(displayValues, fillMode, percentScaleActive ? 1 : undefined) }, [comparisonActive, displayValues, percentScaleActive]) - const scopedKeys = useMemo( - () => Object.keys(bundle.plazasByIbgeCode), - [bundle.plazasByIbgeCode], - ) + const scopedKeys = useMemo(() => Object.keys(bundle.plazasByIbgeCode), [bundle.plazasByIbgeCode]) const metricLabel = useMemo(() => { if (comparisonActive) { @@ -90,9 +99,10 @@ export const PlazaMapPanel = ({ } return `participação nos válidos (${year})` } - if (year === 2026) return 'votos estimados 2026' + if (year === 2026) + return `votos estimados 2026 (${voteEstimateScenarioLabels[estimateScenario].toLowerCase()})` return `votos de ${bundle.candidateName} em ${year}` - }, [bundle.candidateName, comparisonActive, percentScaleActive, year]) + }, [bundle.candidateName, comparisonActive, estimateScenario, percentScaleActive, year]) const setCompare = (candidateNumber: string) => { const params = new URLSearchParams(searchParams.toString()) @@ -193,6 +203,14 @@ export const PlazaMapPanel = ({ ) : null} + {year === 2026 && !comparisonActive ? ( + + ) : null} Comparar com - {formatElectionNumber(zone.votesByYear[String(year)] ?? 0)} + {formatElectionNumber( + year === 2026 + ? (zone.votes2026ByScenario[estimateScenario] ?? 0) + : (zone.votesByYear[String(year)] ?? 0), + )} ))} diff --git a/src/components/campaign/PlazaPledgesPanel.tsx b/src/components/campaign/PlazaPledgesPanel.tsx index e1444d31..c1220cf4 100644 --- a/src/components/campaign/PlazaPledgesPanel.tsx +++ b/src/components/campaign/PlazaPledgesPanel.tsx @@ -1,7 +1,12 @@ -import { PledgeEstimateForm } from '@/components/campaign/PledgeEstimateForm' +import { hasAnyEstimate, PledgeEstimateForm } from '@/components/campaign/PledgeEstimateForm' import { Badge } from '@/components/ui/Badge' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' -import type { StaffPledgeRow } from '@/utilities/votePledgeData' +import { + effectivePledgeVotesForScenario, + formatVoteEstimateRange, + getVoteEstimateForScenario, +} from '@/utilities/voteEstimate' +import { type StaffPledgeRow } from '@/utilities/votePledgeData' const voteFormatter = new Intl.NumberFormat('pt-BR') const dateFormatter = new Intl.DateTimeFormat('pt-BR') @@ -18,7 +23,9 @@ type PlazaPledgesPanelProps = { export const PlazaPledgesPanel = ({ pledges, estimateFormAction }: PlazaPledgesPanelProps) => { const declaredTotal = pledges.reduce((total, pledge) => total + pledge.declaredVotes, 0) const effectiveTotal = pledges.reduce( - (total, pledge) => total + (pledge.estimatedVotes ?? pledge.declaredVotes), + (total, pledge) => + total + + effectivePledgeVotesForScenario(pledge.declaredVotes, pledge.estimatedVotes, 'central'), 0, ) @@ -32,8 +39,8 @@ export const PlazaPledgesPanel = ({ pledges, estimateFormAction }: PlazaPledgesP Votos declarados pelas lideranças

- A liderança informa quantos votos traz; a assessoria registra a estimativa real. A - liderança nunca vê o valor estimado. + A liderança informa quantos votos traz; a assessoria registra a estimativa real em três + cenários. A liderança nunca vê o valor estimado.

@@ -47,41 +54,48 @@ export const PlazaPledgesPanel = ({ pledges, estimateFormAction }: PlazaPledgesP
-
Total estimado
+
Total estimado (média)
{voteFormatter.format(effectiveTotal)}
    - {pledges.map((pledge) => ( -
  • -
    -
    - {pledge.contactName} - - Declarou {voteFormatter.format(pledge.declaredVotes)} votos - {pledge.declaredAt - ? ` em ${dateFormatter.format(new Date(pledge.declaredAt))}` - : ''} - + {pledges.map((pledge) => { + const centralEstimate = getVoteEstimateForScenario(pledge.estimatedVotes, 'central') + const estimateRange = formatVoteEstimateRange(pledge.estimatedVotes) + return ( +
  • +
    +
    + {pledge.contactName} + + Declarou {voteFormatter.format(pledge.declaredVotes)} votos + {pledge.declaredAt + ? ` em ${dateFormatter.format(new Date(pledge.declaredAt))}` + : ''} + +
    + {!hasAnyEstimate(pledge.estimatedVotes) ? ( + Sem estimativa + ) : ( + + {centralEstimate != null + ? `Média: ${voteFormatter.format(centralEstimate)}` + : 'Estimativa parcial'} + {estimateRange ? ` · ${estimateRange}` : ''} + + )}
    - {pledge.estimatedVotes == null ? ( - Sem estimativa - ) : ( - - Estimado: {voteFormatter.format(pledge.estimatedVotes)} - - )} - - -
  • - ))} + + + ) + })}
) : ( diff --git a/src/components/campaign/PlazaStrategyCard.tsx b/src/components/campaign/PlazaStrategyCard.tsx index b64c3026..d0b191ed 100644 --- a/src/components/campaign/PlazaStrategyCard.tsx +++ b/src/components/campaign/PlazaStrategyCard.tsx @@ -1,11 +1,16 @@ import { PencilIcon } from 'lucide-react' import Link from 'next/link' +import { StaffPlazaVotesDisplay } from '@/components/campaign/StaffPlazaVotesDisplay' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' -import { StaffPlazaVotesDisplay } from '@/components/campaign/StaffPlazaVotesDisplay' -import { plazaPriorityLabels, politicalTrendBadgeVariant, politicalTrendLabels } from '@/utilities/plazaUi' +import { + plazaPriorityLabels, + politicalTrendBadgeVariant, + politicalTrendLabels, +} from '@/utilities/plazaUi' import type { PlazaDetailViewModel } from '@/utilities/plazaViewModels' +import type { PlazaPledgeCoverageView } from '@/utilities/votePledgeData' const voteFormatter = new Intl.NumberFormat('pt-BR') const dateFormatter = new Intl.DateTimeFormat('pt-BR') @@ -14,12 +19,12 @@ export const PlazaStrategyCard = ({ strategy, plazaSlug, canEdit, - leadershipVoteTotal, + pledgeCoverage, }: { strategy: NonNullable plazaSlug: string canEdit: boolean - leadershipVoteTotal: number + pledgeCoverage: PlazaPledgeCoverageView | null }) => { const goals = strategy.voteGoals const trend = strategy.politicalTrend @@ -65,7 +70,7 @@ export const PlazaStrategyCard = ({
diff --git a/src/components/campaign/PlazaStrategyForm.tsx b/src/components/campaign/PlazaStrategyForm.tsx index f7ee6b0b..9cd42342 100644 --- a/src/components/campaign/PlazaStrategyForm.tsx +++ b/src/components/campaign/PlazaStrategyForm.tsx @@ -2,6 +2,7 @@ import { useActionState, useState } from 'react' +import { VoteEstimateScenarioInputs } from '@/components/campaign/VoteEstimateScenarioInputs' import { Alert, AlertDescription } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' import { Field, FieldError, FieldLabel } from '@/components/ui/field' @@ -196,18 +197,11 @@ export const PlazaStrategyForm = ({ - - Total da Praça - - + {expectedVotesState.message && expectedVotesState.status !== 'success' ? ( {expectedVotesState.message} diff --git a/src/components/campaign/PledgeEstimateForm.tsx b/src/components/campaign/PledgeEstimateForm.tsx index 6912d737..e6058922 100644 --- a/src/components/campaign/PledgeEstimateForm.tsx +++ b/src/components/campaign/PledgeEstimateForm.tsx @@ -2,6 +2,7 @@ import { useActionState } from 'react' +import { VoteEstimateScenarioInputs } from '@/components/campaign/VoteEstimateScenarioInputs' import { Alert, AlertDescription } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' import { Field, FieldError, FieldLabel } from '@/components/ui/field' @@ -9,10 +10,11 @@ import { Input } from '@/components/ui/input' import { Spinner } from '@/components/ui/Spinner' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' import { fieldError } from '@/utilities/campaignFormFields' +import type { VoteEstimateScenarioViewModel } from '@/utilities/voteEstimate' type PledgeEstimateFormProps = { pledgeID: number - currentEstimatedVotes: number | null + currentEstimatedVotes: VoteEstimateScenarioViewModel currentEstimateNote: string | null formAction: ( state: CampaignFormActionState, @@ -20,6 +22,9 @@ type PledgeEstimateFormProps = { ) => Promise } +const hasAnyEstimate = (estimates: VoteEstimateScenarioViewModel): boolean => + estimates.pessimistic != null || estimates.central != null || estimates.optimistic != null + /** Staff-only inline estimate. The leader never sees these fields. */ export const PledgeEstimateForm = ({ pledgeID, @@ -30,43 +35,44 @@ export const PledgeEstimateForm = ({ const [state, submitAction, isPending] = useActionState(formAction, {}) return ( -
+ -
- - - Estimativa do assessor - - - - - - Justificativa - - - - -
- {fieldError(state.fieldErrors, 'estimatedVotes') ? ( - {fieldError(state.fieldErrors, 'estimatedVotes')} + + + + Justificativa + + + + + {fieldError(state.fieldErrors, 'estimatedVotes') || + fieldError(state.fieldErrors, 'pessimistic') || + fieldError(state.fieldErrors, 'central') || + fieldError(state.fieldErrors, 'optimistic') ? ( + + {fieldError(state.fieldErrors, 'estimatedVotes') || + fieldError(state.fieldErrors, 'pessimistic') || + fieldError(state.fieldErrors, 'central') || + fieldError(state.fieldErrors, 'optimistic')} + ) : null} {state.message && state.status !== 'success' ? ( @@ -76,3 +82,5 @@ export const PledgeEstimateForm = ({ ) } + +export { hasAnyEstimate } diff --git a/src/components/campaign/StaffPlazaVotesDisplay.tsx b/src/components/campaign/StaffPlazaVotesDisplay.tsx index a5e67b7d..da6e60d3 100644 --- a/src/components/campaign/StaffPlazaVotesDisplay.tsx +++ b/src/components/campaign/StaffPlazaVotesDisplay.tsx @@ -1,24 +1,105 @@ +import { UsersIcon } from 'lucide-react' + +import { VoteEstimateScenarioStrip } from '@/components/campaign/VoteEstimateScenarioStrip' +import { cn } from '@/lib/utils' +import { + formatVoteEstimateScenarioAriaLabel, + getVoteEstimateForScenario, + hasAnyVoteEstimate, + type VoteEstimateScenario, + type VoteEstimateScenarioViewModel, +} from '@/utilities/voteEstimate' +import type { PlazaPledgeCoverageView } from '@/utilities/votePledgeData' + const voteFormatter = new Intl.NumberFormat('pt-BR') +const pledgeCoverageSubline = ( + coverage: PlazaPledgeCoverageView | null | undefined, +): number | null => { + if (!coverage || coverage.pledgeCount === 0) return null + if (coverage.missingEstimateCount === coverage.pledgeCount) return null + return coverage.effectiveTotal > 0 ? coverage.effectiveTotal : null +} + export const StaffPlazaVotesDisplay = ({ expectedVotes, - leadershipEffectiveTotal, + pledgeCoverage, + activeScenario = 'central', + layout = 'default', + align = 'start', + suppressHoverPreview = false, valueClassName = 'text-lg font-medium tabular-nums', - sublineClassName = 'text-xs text-muted-foreground', }: { - expectedVotes: number | null - leadershipEffectiveTotal: number + expectedVotes: VoteEstimateScenarioViewModel + pledgeCoverage?: PlazaPledgeCoverageView | null + activeScenario?: VoteEstimateScenario + layout?: 'default' | 'compact' + align?: 'start' | 'center' + suppressHoverPreview?: boolean valueClassName?: string - sublineClassName?: string -}) => ( - <> - - {expectedVotes == null ? '—' : voteFormatter.format(expectedVotes)} - - {leadershipEffectiveTotal > 0 ? ( - - Nas lideranças: {voteFormatter.format(leadershipEffectiveTotal)} - - ) : null} - -) +}) => { + const displayValue = getVoteEstimateForScenario(expectedVotes, activeScenario) + const leadershipTotal = pledgeCoverageSubline(pledgeCoverage) + const hasEstimate = hasAnyVoteEstimate(expectedVotes) + const isCompact = layout === 'compact' + + if (isCompact) { + return ( + <> + + {displayValue == null ? '—' : voteFormatter.format(displayValue)} + + {hasEstimate && !suppressHoverPreview ? ( +
+
+ +
+
+ ) : null} + {formatVoteEstimateScenarioAriaLabel(expectedVotes)} + + ) + } + + return ( +
+ {displayValue == null ? ( + — + ) : ( + {voteFormatter.format(displayValue)} + )} + {hasEstimate ? ( + + ) : null} + {leadershipTotal != null ? ( + + + ) : null} +
+ ) +} diff --git a/src/components/campaign/VoteEstimateScenarioField.tsx b/src/components/campaign/VoteEstimateScenarioField.tsx new file mode 100644 index 00000000..5b4bb7be --- /dev/null +++ b/src/components/campaign/VoteEstimateScenarioField.tsx @@ -0,0 +1,55 @@ +'use client' + +import { CampaignInfoHint } from '@/components/campaign/CampaignInfoHint' +import { Field, FieldLabel } from '@/components/ui/field' +import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { cn } from '@/lib/utils' +import { + VOTE_ESTIMATE_SCENARIOS, + voteEstimateScenarioLabels, + type VoteEstimateScenario, +} from '@/utilities/voteEstimate' + +export const VOTE_ESTIMATE_SCENARIO_OVERVIEW_HINT = + 'Troca o total desta visão geral e os votos na lista. No mapa, a cor só muda com Ano 2026.' + +export const VOTE_ESTIMATE_SCENARIO_MAP_HINT = 'Pinta o mapa e os totais da lista abaixo.' + +type VoteEstimateScenarioFieldProps = { + id: string + value: VoteEstimateScenario + onChange: (scenario: VoteEstimateScenario) => void + hint: string + className?: string +} + +export const VoteEstimateScenarioField = ({ + id, + value, + onChange, + hint, + className, +}: VoteEstimateScenarioFieldProps) => ( + +
+ + Cenário + + +

{hint}

+
+
+ onChange(event.target.value as VoteEstimateScenario)} + className="min-h-11 w-full" + > + {VOTE_ESTIMATE_SCENARIOS.map((scenario) => ( + + {voteEstimateScenarioLabels[scenario]} + + ))} + +
+) diff --git a/src/components/campaign/VoteEstimateScenarioInputs.tsx b/src/components/campaign/VoteEstimateScenarioInputs.tsx new file mode 100644 index 00000000..05d7999c --- /dev/null +++ b/src/components/campaign/VoteEstimateScenarioInputs.tsx @@ -0,0 +1,167 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + +import { VoteEstimateScenarioStrip } from '@/components/campaign/VoteEstimateScenarioStrip' +import { Field, FieldLabel } from '@/components/ui/field' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import { + VOTE_ESTIMATE_SCENARIOS, + voteEstimateScenarioLabels, + voteEstimatesEqual, + type VoteEstimateScenario, + type VoteEstimateScenarioViewModel, +} from '@/utilities/voteEstimate' + +const scenarioFieldName = ( + fieldPrefix: 'estimatedVotes' | 'expectedVotes', + scenario: VoteEstimateScenario, +): string => `${fieldPrefix}${scenario.charAt(0).toUpperCase()}${scenario.slice(1)}` + +const parseScenarioInput = (raw: string): number | null => { + const trimmed = raw.trim() + if (!trimmed) return null + const parsed = Number(trimmed) + if (!Number.isFinite(parsed)) return null + return Math.trunc(parsed) +} + +const toDraftText = (value: number | null | undefined): string => + value == null ? '' : String(value) + +const draftsFromValues = ( + values: VoteEstimateScenarioViewModel, +): Record => ({ + pessimistic: toDraftText(values.pessimistic), + central: toDraftText(values.central), + optimistic: toDraftText(values.optimistic), +}) + +const valuesFromDrafts = ( + drafts: Record, +): VoteEstimateScenarioViewModel => ({ + pessimistic: parseScenarioInput(drafts.pessimistic), + central: parseScenarioInput(drafts.central), + optimistic: parseScenarioInput(drafts.optimistic), +}) + +const compactInputClassName = + 'h-9 min-h-9 rounded-md px-1.5 text-center tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none' + +type VoteEstimateScenarioInputsProps = { + fieldPrefix: 'estimatedVotes' | 'expectedVotes' + values: VoteEstimateScenarioViewModel + idPrefix: string + variant?: 'labeled' | 'compact' + autoFocusScenario?: VoteEstimateScenario + onValuesChange?: (values: VoteEstimateScenarioViewModel) => void +} + +const CompactVoteEstimateScenarioInputs = ({ + fieldPrefix, + values, + idPrefix, + autoFocusScenario, + onValuesChange, +}: Omit & { + onValuesChange: (values: VoteEstimateScenarioViewModel) => void +}) => { + const [drafts, setDrafts] = useState(() => draftsFromValues(values)) + const committedValuesRef = useRef(values) + + useEffect(() => { + if (voteEstimatesEqual(values, committedValuesRef.current)) return + committedValuesRef.current = values + setDrafts(draftsFromValues(values)) + }, [values]) + + const handleDraftChange = (scenario: VoteEstimateScenario, raw: string) => { + const sanitized = raw.replace(/\D/g, '') + const nextDrafts = { ...drafts, [scenario]: sanitized } + setDrafts(nextDrafts) + const nextValues = valuesFromDrafts(nextDrafts) + committedValuesRef.current = nextValues + onValuesChange(nextValues) + } + + return ( +
+ +
+ {VOTE_ESTIMATE_SCENARIOS.map((scenario) => ( + handleDraftChange(scenario, event.currentTarget.value)} + onFocus={(event) => event.currentTarget.select()} + aria-label={voteEstimateScenarioLabels[scenario]} + className={cn( + compactInputClassName, + scenario === 'central' + ? 'border-primary/50 bg-primary/5 font-medium ring-1 ring-primary/15' + : 'bg-muted/30', + )} + /> + ))} +
+
+ ) +} + +export const VoteEstimateScenarioInputs = ({ + fieldPrefix, + values, + idPrefix, + variant = 'labeled', + autoFocusScenario, + onValuesChange, +}: VoteEstimateScenarioInputsProps) => { + if (variant === 'compact') { + if (!onValuesChange) { + throw new Error('VoteEstimateScenarioInputs compact variant requires onValuesChange.') + } + return ( + + ) + } + + return ( +
+ {VOTE_ESTIMATE_SCENARIOS.map((scenario) => ( + + + {voteEstimateScenarioLabels[scenario]} + + + + ))} +
+ ) +} diff --git a/src/components/campaign/VoteEstimateScenarioStrip.tsx b/src/components/campaign/VoteEstimateScenarioStrip.tsx new file mode 100644 index 00000000..ef6d41e6 --- /dev/null +++ b/src/components/campaign/VoteEstimateScenarioStrip.tsx @@ -0,0 +1,147 @@ +import { cn } from '@/lib/utils' +import { + formatVoteEstimateScenarioAriaLabel, + VOTE_ESTIMATE_SCENARIOS, + voteEstimateScenarioLabels, + type VoteEstimateScenario, + type VoteEstimateScenarioViewModel, +} from '@/utilities/voteEstimate' + +const voteFormatter = new Intl.NumberFormat('pt-BR') + +type VoteEstimateScenarioStripProps = { + values: VoteEstimateScenarioViewModel + activeScenario?: VoteEstimateScenario + className?: string + /** @deprecated Prefer labelMode */ + showLabels?: boolean + labelMode?: 'all' | 'endpoints' | 'none' + markerMode?: 'all' | 'active-only' + stretch?: boolean +} + +export const VoteEstimateScenarioStrip = ({ + values, + activeScenario = 'central', + className, + showLabels = true, + labelMode, + markerMode = 'all', + stretch = false, +}: VoteEstimateScenarioStripProps) => { + const resolvedLabelMode = labelMode ?? (showLabels ? 'all' : 'none') + const activeOnlyMarkers = markerMode === 'active-only' + const trackClassName = stretch + ? 'w-full min-w-0 max-w-none' + : 'w-full min-w-[4.5rem] max-w-[5.5rem]' + const filled = VOTE_ESTIMATE_SCENARIOS.map((scenario) => values[scenario]).filter( + (value): value is number => value != null, + ) + const min = filled.length > 0 ? Math.min(...filled) : null + const max = filled.length > 0 ? Math.max(...filled) : null + const hasSpread = min != null && max != null && max > min + const activeValue = values[activeScenario] + const activeMarkerPosition = + activeValue != null && min != null && max != null + ? hasSpread + ? ((activeValue - min) / (max - min)) * 100 + : 50 + : null + + const activeMarker = + activeMarkerPosition != null ? ( + + ) : null + + return ( +
+ {activeOnlyMarkers ? ( +
+ {activeMarker} +
+ ) : hasSpread ? ( +
+ {VOTE_ESTIMATE_SCENARIOS.map((scenario) => { + const value = values[scenario] + if (value == null) return null + const position = ((value - min) / (max - min)) * 100 + const isActive = scenario === activeScenario + return ( + + ) + })} +
+ ) : ( +
+ {VOTE_ESTIMATE_SCENARIOS.map((scenario) => { + const value = values[scenario] + const isActive = scenario === activeScenario + return ( + + ) + })} +
+ )} + {resolvedLabelMode === 'all' ? ( +
+ {VOTE_ESTIMATE_SCENARIOS.map((scenario) => { + const value = values[scenario] + return ( + + {value == null ? '·' : voteFormatter.format(value)} + + ) + })} +
+ ) : resolvedLabelMode === 'endpoints' ? ( +
+ + {values.pessimistic == null ? '·' : voteFormatter.format(values.pessimistic)} + + + {values.optimistic == null ? '·' : voteFormatter.format(values.optimistic)} + +
+ ) : null} +
+ ) +} diff --git a/src/lib/formData.ts b/src/lib/formData.ts index 080867ec..5f980460 100644 --- a/src/lib/formData.ts +++ b/src/lib/formData.ts @@ -2,6 +2,8 @@ import 'server-only' import { z, type ZodError } from 'zod' +import type { VoteEstimateScenarioFields } from '@/utilities/voteEstimate' + export class FormDataBoundaryError extends Error { field: string @@ -92,6 +94,38 @@ export const optionalIntegerFormValue = ( return requiredIntegerFormValue(formData, field, options) } +const optionalNullableIntegerFormValue = ( + formData: FormData, + field: string, + options?: { minimum?: number; maximum?: number }, +): number | null | undefined => { + const entry = formEntry(formData, field) + if (!entry.present) return undefined + if (!entry.value.trim()) return null + return requiredIntegerFormValue(formData, field, options) +} + +export const voteEstimateScenarioFromForm = ( + formData: FormData, + prefix: 'estimatedVotes' | 'expectedVotes', +): VoteEstimateScenarioFields => ({ + pessimistic: + optionalNullableIntegerFormValue(formData, `${prefix}Pessimistic`, { + minimum: 0, + maximum: 1_000_000, + }) ?? null, + central: + optionalNullableIntegerFormValue(formData, `${prefix}Central`, { + minimum: 0, + maximum: 1_000_000, + }) ?? null, + optimistic: + optionalNullableIntegerFormValue(formData, `${prefix}Optimistic`, { + minimum: 0, + maximum: 1_000_000, + }) ?? null, +}) + export const requiredRelationshipFormValue = (formData: FormData, field: string): number => { const entry = formEntry(formData, field) if (!entry.present) throw new FormDataBoundaryError(field, 'Campo obrigatório.') diff --git a/src/lib/schemas/plaza.ts b/src/lib/schemas/plaza.ts index e8b6232f..a3ec221e 100644 --- a/src/lib/schemas/plaza.ts +++ b/src/lib/schemas/plaza.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { positiveRelationshipId, trimmedNullableText } from '@/lib/schemas/primitives' +import { voteEstimateScenarioFieldsSchema } from '@/lib/schemas/votePledge' export const politicalTrendStatuses = ['favoravel', 'neutra', 'desfavoravel'] as const export type PoliticalTrendStatusValue = (typeof politicalTrendStatuses)[number] @@ -48,7 +49,7 @@ export const plazaAdvisorsAssignmentSchema = z.object({ export const plazaExpectedVotesSchema = z.object({ plaza: positiveRelationshipId, - expectedVotes: z.number().int().min(0).max(1_000_000).nullable(), + expectedVotes: voteEstimateScenarioFieldsSchema, }) export type PlazaStrategyUpdateInput = z.input diff --git a/src/lib/schemas/votePledge.ts b/src/lib/schemas/votePledge.ts index 183d4adb..54d8c9b5 100644 --- a/src/lib/schemas/votePledge.ts +++ b/src/lib/schemas/votePledge.ts @@ -1,6 +1,29 @@ import { z } from 'zod' import { positiveRelationshipId, trimmedNullableText } from '@/lib/schemas/primitives' +import { + getVoteEstimateOrderViolation, + VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, +} from '@/utilities/voteEstimate' + +const optionalEstimate = z.number().int().min(0).max(1_000_000).nullable().optional() + +export const voteEstimateScenarioFieldsSchema = z + .object({ + pessimistic: optionalEstimate, + central: optionalEstimate, + optimistic: optionalEstimate, + }) + .superRefine((value, ctx) => { + const violation = getVoteEstimateOrderViolation(value) + if (violation) { + ctx.addIssue({ + code: 'custom', + message: VOTE_ESTIMATE_ORDER_ERROR_MESSAGE, + path: [violation], + }) + } + }) /** A leader (or staff on their behalf) declares votes for one linked plaza. */ export const declareVotesSchema = z.object({ @@ -13,7 +36,7 @@ export const declareVotesSchema = z.object({ /** Staff record their internal estimate for one pledge. */ export const estimateVotesSchema = z.object({ pledge: positiveRelationshipId, - estimatedVotes: z.number().int().min(0).max(1_000_000).nullable(), + estimatedVotes: voteEstimateScenarioFieldsSchema, estimateNote: trimmedNullableText(1000), }) diff --git a/src/migrations/20260723_124200_add_vote_estimate_scenarios.ts b/src/migrations/20260723_124200_add_vote_estimate_scenarios.ts new file mode 100644 index 00000000..9bb5b264 --- /dev/null +++ b/src/migrations/20260723_124200_add_vote_estimate_scenarios.ts @@ -0,0 +1,50 @@ +import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + ALTER TABLE "vote_pledge" ADD COLUMN "estimated_votes_pessimistic" numeric; + ALTER TABLE "vote_pledge" ADD COLUMN "estimated_votes_central" numeric; + ALTER TABLE "vote_pledge" ADD COLUMN "estimated_votes_optimistic" numeric; + + UPDATE "vote_pledge" + SET "estimated_votes_central" = "estimated_votes" + WHERE "estimated_votes" IS NOT NULL; + + DROP INDEX IF EXISTS "vote_pledge_estimated_votes_idx"; + ALTER TABLE "vote_pledge" DROP COLUMN "estimated_votes"; + + ALTER TABLE "plaza" ADD COLUMN "expected_votes_pessimistic" numeric; + ALTER TABLE "plaza" ADD COLUMN "expected_votes_central" numeric; + ALTER TABLE "plaza" ADD COLUMN "expected_votes_optimistic" numeric; + + UPDATE "plaza" + SET "expected_votes_central" = "expected_votes" + WHERE "expected_votes" IS NOT NULL; + + ALTER TABLE "plaza" DROP COLUMN "expected_votes"; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "vote_pledge" ADD COLUMN "estimated_votes" numeric; + UPDATE "vote_pledge" + SET "estimated_votes" = "estimated_votes_central" + WHERE "estimated_votes_central" IS NOT NULL; + `) + await db.execute(sql` + ALTER TABLE "vote_pledge" DROP COLUMN "estimated_votes_pessimistic"; + ALTER TABLE "vote_pledge" DROP COLUMN "estimated_votes_central"; + ALTER TABLE "vote_pledge" DROP COLUMN "estimated_votes_optimistic"; + CREATE INDEX "vote_pledge_estimated_votes_idx" ON "vote_pledge" USING btree ("estimated_votes"); + `) + await db.execute(sql` + ALTER TABLE "plaza" ADD COLUMN "expected_votes" numeric; + UPDATE "plaza" + SET "expected_votes" = "expected_votes_central" + WHERE "expected_votes_central" IS NOT NULL; + ALTER TABLE "plaza" DROP COLUMN "expected_votes_pessimistic"; + ALTER TABLE "plaza" DROP COLUMN "expected_votes_central"; + ALTER TABLE "plaza" DROP COLUMN "expected_votes_optimistic"; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 206caf75..1ac67e01 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -1,24 +1,25 @@ -import * as migration_20260715_163458_initial from './20260715_163458_initial'; -import * as migration_20260715_163500_consent_text_to_jsonb from './20260715_163500_consent_text_to_jsonb'; -import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_add_post_and_tag'; -import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden'; -import * as migration_20260716_010420_add_campaign_user from './20260716_010420_add_campaign_user'; -import * as migration_20260718_010733_consolidate_campaign_schema from './20260718_010733_consolidate_campaign_schema'; -import * as migration_20260718_190559_territorio_multi_municipio_bairro from './20260718_190559_territorio_multi_municipio_bairro'; -import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results'; -import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter'; -import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan'; -import * as migration_20260719_011015_add_supporter_import_batch from './20260719_011015_add_supporter_import_batch'; -import * as migration_20260719_014906_action_plan_list_perf from './20260719_014906_action_plan_list_perf'; -import * as migration_20260719_020000_add_contact_trgm_index from './20260719_020000_add_contact_trgm_index'; -import * as migration_20260719_054522_add_nucleus_goals_strategy from './20260719_054522_add_nucleus_goals_strategy'; -import * as migration_20260719_054706_add_privacy_policy_global from './20260719_054706_add_privacy_policy_global'; -import * as migration_20260719_054707_seed_onda0_consent_and_privacy from './20260719_054707_seed_onda0_consent_and_privacy'; -import * as migration_20260719_061302_add_campaign_user_avatar from './20260719_061302_add_campaign_user_avatar'; -import * as migration_20260721_020109_remodel_plazas from './20260721_020109_remodel_plazas'; -import * as migration_20260721_133444_add_plaza_expected_votes from './20260721_133444_add_plaza_expected_votes'; -import * as migration_20260721_133531_add_petition_facebook_pixel_id from './20260721_133531_add_petition_facebook_pixel_id'; -import * as migration_20260723_025513_add_import_export_plugin from './20260723_025513_add_import_export_plugin'; +import * as migration_20260715_163458_initial from './20260715_163458_initial' +import * as migration_20260715_163500_consent_text_to_jsonb from './20260715_163500_consent_text_to_jsonb' +import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_add_post_and_tag' +import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden' +import * as migration_20260716_010420_add_campaign_user from './20260716_010420_add_campaign_user' +import * as migration_20260718_010733_consolidate_campaign_schema from './20260718_010733_consolidate_campaign_schema' +import * as migration_20260718_190559_territorio_multi_municipio_bairro from './20260718_190559_territorio_multi_municipio_bairro' +import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results' +import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter' +import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan' +import * as migration_20260719_011015_add_supporter_import_batch from './20260719_011015_add_supporter_import_batch' +import * as migration_20260719_014906_action_plan_list_perf from './20260719_014906_action_plan_list_perf' +import * as migration_20260719_020000_add_contact_trgm_index from './20260719_020000_add_contact_trgm_index' +import * as migration_20260719_054522_add_nucleus_goals_strategy from './20260719_054522_add_nucleus_goals_strategy' +import * as migration_20260719_054706_add_privacy_policy_global from './20260719_054706_add_privacy_policy_global' +import * as migration_20260719_054707_seed_onda0_consent_and_privacy from './20260719_054707_seed_onda0_consent_and_privacy' +import * as migration_20260719_061302_add_campaign_user_avatar from './20260719_061302_add_campaign_user_avatar' +import * as migration_20260721_020109_remodel_plazas from './20260721_020109_remodel_plazas' +import * as migration_20260721_133444_add_plaza_expected_votes from './20260721_133444_add_plaza_expected_votes' +import * as migration_20260721_133531_add_petition_facebook_pixel_id from './20260721_133531_add_petition_facebook_pixel_id' +import * as migration_20260723_025513_add_import_export_plugin from './20260723_025513_add_import_export_plugin' +import * as migration_20260723_124200_add_vote_estimate_scenarios from './20260723_124200_add_vote_estimate_scenarios' export const migrations = [ { @@ -124,6 +125,11 @@ export const migrations = [ { up: migration_20260723_025513_add_import_export_plugin.up, down: migration_20260723_025513_add_import_export_plugin.down, - name: '20260723_025513_add_import_export_plugin' + name: '20260723_025513_add_import_export_plugin', }, -]; + { + up: migration_20260723_124200_add_vote_estimate_scenarios.up, + down: migration_20260723_124200_add_vote_estimate_scenarios.down, + name: '20260723_124200_add_vote_estimate_scenarios', + }, +] diff --git a/src/payload-types.ts b/src/payload-types.ts index 6df63032..a5ec9d05 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -412,9 +412,13 @@ export interface Plaza { minimum?: number | null; }; /** - * Total esperado da Praça — distinto das metas de cenário e da soma das lideranças. + * Total esperado da Praça por cenário — distinto das metas de planejamento e da soma das lideranças. */ - expectedVotes?: number | null; + expectedVotes?: { + pessimistic?: number | null; + central?: number | null; + optimistic?: number | null; + }; /** * Leitura de conjuntura registrada pela coordenação (alianças, prefeitos, disputas locais) — não é série numérica. */ @@ -508,9 +512,13 @@ export interface VotePledge { declaredAt?: string | null; declaredBy?: (number | null) | CampaignUser; /** - * Estimativa interna do valor real. A liderança nunca vê este número. + * Faixa interna pessimista / média / otimista. A liderança nunca vê estes números. */ - estimatedVotes?: number | null; + estimatedVotes?: { + pessimistic?: number | null; + central?: number | null; + optimistic?: number | null; + }; estimateNote?: string | null; estimatedBy?: (number | null) | CampaignUser; estimatedAt?: string | null; @@ -1318,7 +1326,13 @@ export interface PlazaSelect { regular?: T; minimum?: T; }; - expectedVotes?: T; + expectedVotes?: + | T + | { + pessimistic?: T; + central?: T; + optimistic?: T; + }; politicalTrend?: | T | { @@ -1390,7 +1404,13 @@ export interface VotePledgeSelect { declaredVotes?: T; declaredAt?: T; declaredBy?: T; - estimatedVotes?: T; + estimatedVotes?: + | T + | { + pessimistic?: T; + central?: T; + optimistic?: T; + }; estimateNote?: T; estimatedBy?: T; estimatedAt?: T; diff --git a/src/utilities/campaignDashboardData.ts b/src/utilities/campaignDashboardData.ts index 4f5bef61..55f5b67a 100644 --- a/src/utilities/campaignDashboardData.ts +++ b/src/utilities/campaignDashboardData.ts @@ -3,6 +3,7 @@ import type { Payload } from 'payload' import type { CampaignUser, VotePledge } from '@/payload-types' import { isCampaignStaff } from '@/utilities/campaignAccess' import { relationshipId, requireRelationshipId } from '@/utilities/relationship' +import type { VoteEstimateScenario } from '@/utilities/voteEstimate' import { aggregatePledgesByPlaza, loadLeaderPledges, @@ -16,7 +17,7 @@ export type StaffDashboardView = { plazaCount: number withAdvisorCount: number highPriorityCount: number - staffVoteTotal: number + staffVoteTotalByScenario: Record declaredVotesTotal: number pledgeCount: number missingEstimateCount: number @@ -86,18 +87,19 @@ const buildStaffDashboard = async ( const plazaById = new Map(plazas.docs.map((plaza) => [plaza.id, plaza])) const pledgeAggregates = await aggregatePledgesByPlaza(payload, plazaIDs) - const { - staffVoteTotal, - declaredVotesTotal, - pledgeCount, - missingEstimateCount, - } = rollupPlazaStaffVotes(plazas.docs, pledgeAggregates) + const rollup = rollupPlazaStaffVotes(plazas.docs, pledgeAggregates) + const { staffVoteTotalByScenario, declaredVotesTotal, pledgeCount, missingEstimateCount } = rollup const missingEstimatePledges = plazaIDs.length ? await payload.find({ collection: 'votePledge', where: { - and: [{ plaza: { in: plazaIDs } }, { estimatedVotes: { exists: false } }], + and: [ + { plaza: { in: plazaIDs } }, + { 'estimatedVotes.pessimistic': { equals: null } }, + { 'estimatedVotes.central': { equals: null } }, + { 'estimatedVotes.optimistic': { equals: null } }, + ], }, depth: 0, limit: 5, @@ -146,7 +148,7 @@ const buildStaffDashboard = async ( plazaCount: plazas.docs.length, withAdvisorCount: plazas.docs.filter((plaza) => (plaza.advisors ?? []).length > 0).length, highPriorityCount: plazas.docs.filter((plaza) => plaza.priority === 'alta').length, - staffVoteTotal, + staffVoteTotalByScenario: { ...staffVoteTotalByScenario }, declaredVotesTotal, pledgeCount, missingEstimateCount, diff --git a/src/utilities/plazaMapData.ts b/src/utilities/plazaMapData.ts index 3cfd9179..81c085c8 100644 --- a/src/utilities/plazaMapData.ts +++ b/src/utilities/plazaMapData.ts @@ -19,6 +19,11 @@ import { type PlazaListSearchParams, type PlazaListState, } from '@/utilities/plazaUi' +import { + DEFAULT_VOTE_ESTIMATE_SCENARIO, + VOTE_ESTIMATE_SCENARIOS, + type VoteEstimateScenario, +} from '@/utilities/voteEstimate' import { aggregatePledgesByPlaza, emptyPlazaPledgeAggregate, @@ -26,14 +31,17 @@ import { type PlazaPledgeAggregate, } from '@/utilities/votePledgeData' -export type { PlazasByIbgeCode, PlazaMapSlugEntry } from '@/utilities/plazaMapNavigation' +export type { PlazaMapSlugEntry, PlazasByIbgeCode } from '@/utilities/plazaMapNavigation' export const PLAZA_MAP_YEARS = [...HISTORICAL_SERIES_YEARS, 2026] as const export type PlazaMapYear = (typeof PLAZA_MAP_YEARS)[number] export type PlazaMapScaleMode = 'absolute' | 'percentValid' -export const PLAZA_MAP_SCALE_MODES = ['percentValid', 'absolute'] as const satisfies readonly PlazaMapScaleMode[] +export const PLAZA_MAP_SCALE_MODES = [ + 'percentValid', + 'absolute', +] as const satisfies readonly PlazaMapScaleMode[] export const plazaMapScaleModeLabels: Record = { absolute: 'Total (votos)', @@ -51,6 +59,7 @@ export type PlazaZoneBreakdownRow = { slug: string name: string votesByYear: Record + votes2026ByScenario: Record } export type PlazaMapComparison = { @@ -61,8 +70,10 @@ export type PlazaMapComparison = { } export type PlazaMapBundle = { - /** year (as string) → codarea → value. 2026 = expectedVotes ?? pledge effective total. */ + /** year (as string) → codarea → value. 2026 = cenário médio (central). */ valuesByYear: Record> + /** 2026 totals per estimate scenario (codarea → votes). */ + values2026ByScenario: Record> /** year (as string) → codarea → votosValidos (federal T1). 2026 reuses 2022. */ validVotesByYear: Record> /** IBGE codarea → accessible plaza slugs for map click navigation. */ @@ -79,7 +90,11 @@ export type ScopedPlaza = { name: string kind: 'municipio' | 'zona' ibgeCode: string - expectedVotes: number | null + expectedVotes?: { + pessimistic?: number | null + central?: number | null + optimistic?: number | null + } | null geography: PlazaElectionGeography } @@ -89,7 +104,11 @@ type ScopedPlazaDoc = { name: string kind: 'municipio' | 'zona' ibgeCode: string - expectedVotes?: number | null + expectedVotes?: { + pessimistic?: number | null + central?: number | null + optimistic?: number | null + } | null } export const scopePlazasFromDocs = (docs: ReadonlyArray): ScopedPlaza[] => @@ -175,18 +194,41 @@ export const buildPlazaMapBundleFromPlazas = async ( } validVotesByYear['2026'] = validVotesByYear['2022'] ?? {} - const pledgeValues: Record = {} + const pledgeValuesByScenario = Object.fromEntries( + VOTE_ESTIMATE_SCENARIOS.map((scenario) => [scenario, {} as Record]), + ) as Record> + const zoneVotes2026BySlug = new Map>() + const emptyZoneScenarioVotes = (): Record => ({ + pessimistic: 0, + central: 0, + optimistic: 0, + }) + for (const plaza of plazas) { const aggregate = pledgeAggregates.get(plaza.id) ?? emptyPlazaPledgeAggregate - const votes = resolvePlazaStaffVoteTotal(plaza.expectedVotes, aggregate.effectiveTotal) - if (votes > 0) pledgeValues[plaza.ibgeCode] = (pledgeValues[plaza.ibgeCode] ?? 0) + votes - if (plaza.kind === 'zona') { - const bySlug = zoneVotesBySlug.get(plaza.slug) ?? {} - bySlug['2026'] = votes - zoneVotesBySlug.set(plaza.slug, bySlug) + for (const scenario of VOTE_ESTIMATE_SCENARIOS) { + const votes = resolvePlazaStaffVoteTotal( + plaza.expectedVotes, + aggregate.effectiveByScenario[scenario], + scenario, + ) + if (votes > 0) { + pledgeValuesByScenario[scenario][plaza.ibgeCode] = + (pledgeValuesByScenario[scenario][plaza.ibgeCode] ?? 0) + votes + } + if (plaza.kind === 'zona') { + const byScenario = zoneVotes2026BySlug.get(plaza.slug) ?? emptyZoneScenarioVotes() + byScenario[scenario] = votes + zoneVotes2026BySlug.set(plaza.slug, byScenario) + if (scenario === DEFAULT_VOTE_ESTIMATE_SCENARIO) { + const bySlug = zoneVotesBySlug.get(plaza.slug) ?? {} + bySlug['2026'] = votes + zoneVotesBySlug.set(plaza.slug, bySlug) + } + } } } - valuesByYear['2026'] = pledgeValues + valuesByYear['2026'] = pledgeValuesByScenario[DEFAULT_VOTE_ESTIMATE_SCENARIO] const zoneBreakdown = plazas .filter((plaza) => plaza.kind === 'zona') @@ -194,6 +236,7 @@ export const buildPlazaMapBundleFromPlazas = async ( slug: plaza.slug, name: plaza.name, votesByYear: zoneVotesBySlug.get(plaza.slug) ?? {}, + votes2026ByScenario: zoneVotes2026BySlug.get(plaza.slug) ?? emptyZoneScenarioVotes(), })) .sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')) @@ -247,6 +290,7 @@ export const buildPlazaMapBundleFromPlazas = async ( return { valuesByYear, + values2026ByScenario: pledgeValuesByScenario, validVotesByYear, plazasByIbgeCode: buildPlazasByIbgeCode(plazas), zoneBreakdown, diff --git a/src/utilities/plazaPageData.ts b/src/utilities/plazaPageData.ts index d65080e1..00d9b71f 100644 --- a/src/utilities/plazaPageData.ts +++ b/src/utilities/plazaPageData.ts @@ -22,6 +22,7 @@ import { type PlazaListViewModel, } from '@/utilities/plazaViewModels' import { relationshipId } from '@/utilities/relationship' +import { type VoteEstimateScenario } from '@/utilities/voteEstimate' import { aggregatePledgesByPlaza, rollupPlazaStaffVotes, @@ -49,7 +50,7 @@ export class PlazaNotFoundError extends Error { export type PlazaListOverviewData = { plazaCount: number - staffVoteTotal: number + staffVoteTotalByScenario: Record pledgeCount: number missingEstimateCount: number withAdvisorCount: number @@ -116,15 +117,12 @@ export const loadPlazaListPageBundle = async ( ) if (isStaff) { - const { staffVoteTotal, pledgeCount, missingEstimateCount } = rollupPlazaStaffVotes( - filteredResult.docs, - pledgeAggregates, - ) + const rollup = rollupPlazaStaffVotes(filteredResult.docs, pledgeAggregates) overview = { plazaCount: filteredResult.docs.length, - staffVoteTotal, - pledgeCount, - missingEstimateCount, + staffVoteTotalByScenario: { ...rollup.staffVoteTotalByScenario }, + pledgeCount: rollup.pledgeCount, + missingEstimateCount: rollup.missingEstimateCount, withAdvisorCount: filteredResult.docs.filter((plaza) => (plaza.advisors ?? []).length > 0) .length, } @@ -144,10 +142,7 @@ export const loadPlazaListPageBundle = async ( return { plazas: paginatedResult.docs.map((plaza) => - toPlazaListViewModel( - plaza as Plaza, - isStaff ? pledgeAggregates.get(plaza.id) : undefined, - ), + toPlazaListViewModel(plaza as Plaza, isStaff ? pledgeAggregates.get(plaza.id) : undefined), ), totalDocs: paginatedResult.totalDocs, totalPages: paginatedResult.totalPages, diff --git a/src/utilities/plazaViewModels.ts b/src/utilities/plazaViewModels.ts index 305ccdfe..03d8233f 100644 --- a/src/utilities/plazaViewModels.ts +++ b/src/utilities/plazaViewModels.ts @@ -1,12 +1,16 @@ import type { Payload } from 'payload' import type { CampaignUser, Plaza } from '@/payload-types' -import type { PoliticalTrendStatus } from '@/utilities/plazaUi' import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess' +import type { PoliticalTrendStatus } from '@/utilities/plazaUi' import { relationshipId } from '@/utilities/relationship' +import { + toVoteEstimateScenarioViewModel, + type VoteEstimateScenarioViewModel, +} from '@/utilities/voteEstimate' import { toVoteGoalsViewModel, type VoteGoalsViewModel } from '@/utilities/voteGoals' import type { PlazaPledgeAggregate } from '@/utilities/votePledgeData' -import { emptyPlazaPledgeAggregate } from '@/utilities/votePledgeData' +import { createEmptyPlazaPledgeAggregate } from '@/utilities/votePledgeData' export const plazaListSelect = { name: true, @@ -45,7 +49,7 @@ export type PlazaListViewModel = { advisorIDs: number[] priority: 'alta' | 'normal' lastUpdateAt: string | null - expectedVotes: number | null + expectedVotes: VoteEstimateScenarioViewModel politicalTrendStatus: PoliticalTrendStatus | null politicalTrendNote: string | null pledges: PlazaPledgeAggregate @@ -66,10 +70,10 @@ export const toPlazaListViewModel = ( advisorIDs: (plaza.advisors ?? []).map(relationshipId).filter((id): id is number => id !== null), priority: plaza.priority === 'alta' ? 'alta' : 'normal', lastUpdateAt: plaza.lastUpdateAt ?? null, - expectedVotes: plaza.expectedVotes ?? null, + expectedVotes: toVoteEstimateScenarioViewModel(plaza.expectedVotes), politicalTrendStatus: plaza.politicalTrend?.status ?? null, politicalTrendNote: plaza.politicalTrend?.note ?? null, - pledges: pledges ?? { ...emptyPlazaPledgeAggregate }, + pledges: pledges ?? createEmptyPlazaPledgeAggregate(), }) export type PlazaAdvisorSummary = { @@ -94,7 +98,7 @@ export type PlazaDetailViewModel = { /** Staff-only block — null for the leader view model. */ strategy: { priority: 'alta' | 'normal' - expectedVotes: number | null + expectedVotes: VoteEstimateScenarioViewModel voteGoals: VoteGoalsViewModel politicalTrend: PlazaPoliticalTrendViewModel strengths: string[] @@ -127,7 +131,7 @@ export const toPlazaDetailViewModel = ( ? null : { priority: plaza.priority === 'alta' ? 'alta' : 'normal', - expectedVotes: plaza.expectedVotes ?? null, + expectedVotes: toVoteEstimateScenarioViewModel(plaza.expectedVotes), voteGoals: toVoteGoalsViewModel(plaza.voteGoals), politicalTrend: { status: plaza.politicalTrend?.status ?? null, diff --git a/src/utilities/voteEstimate.ts b/src/utilities/voteEstimate.ts new file mode 100644 index 00000000..86fe397d --- /dev/null +++ b/src/utilities/voteEstimate.ts @@ -0,0 +1,154 @@ +export const VOTE_ESTIMATE_SCENARIOS = ['pessimistic', 'central', 'optimistic'] as const + +export type VoteEstimateScenario = (typeof VOTE_ESTIMATE_SCENARIOS)[number] + +export const DEFAULT_VOTE_ESTIMATE_SCENARIO: VoteEstimateScenario = 'central' + +export type VoteEstimateScenarioFields = { + pessimistic?: number | null + central?: number | null + optimistic?: number | null +} + +export type VoteEstimateScenarioViewModel = { + pessimistic: number | null + central: number | null + optimistic: number | null +} + +export const voteEstimateScenarioLabels: Record = { + pessimistic: 'Pessimista', + central: 'Média', + optimistic: 'Otimista', +} + +export const VOTE_ESTIMATE_ORDER_ERROR_MESSAGE = + 'As estimativas devem seguir a ordem Pessimista ≤ Média ≤ Otimista quando informadas.' + +/** Returns the scenario key that breaks pessimistic ≤ central ≤ optimistic, if any. */ +export const getVoteEstimateOrderViolation = ( + estimates: VoteEstimateScenarioFields | null | undefined, +): VoteEstimateScenario | null => { + if (!estimates) return null + + const ordered = ( + [ + ['pessimistic', estimates.pessimistic], + ['central', estimates.central], + ['optimistic', estimates.optimistic], + ] as const + ).filter((entry): entry is [VoteEstimateScenario, number] => entry[1] != null) + + for (let index = 1; index < ordered.length; index += 1) { + const [, previous] = ordered[index - 1]! + const [key, current] = ordered[index]! + if (previous > current) return key + } + + return null +} + +export const voteEstimatesEqual = ( + left: VoteEstimateScenarioViewModel, + right: VoteEstimateScenarioViewModel, +): boolean => + left.pessimistic === right.pessimistic && + left.central === right.central && + left.optimistic === right.optimistic + +export const toVoteEstimateScenarioViewModel = ( + estimates?: VoteEstimateScenarioFields | null, +): VoteEstimateScenarioViewModel => ({ + pessimistic: estimates?.pessimistic ?? null, + central: estimates?.central ?? null, + optimistic: estimates?.optimistic ?? null, +}) + +export const getVoteEstimateForScenario = ( + estimates: VoteEstimateScenarioFields | null | undefined, + scenario: VoteEstimateScenario, +): number | null => estimates?.[scenario] ?? null + +/** Effective staff votes for one pledge in a scenario: estimated[S] ?? declared. */ +export const effectivePledgeVotesForScenario = ( + declaredVotes: number, + estimatedVotes: VoteEstimateScenarioFields | null | undefined, + scenario: VoteEstimateScenario, +): number => getVoteEstimateForScenario(estimatedVotes, scenario) ?? declaredVotes + +/** Staff plaza total for one scenario: expected[S] ?? pledge aggregate for S. */ +export const resolvePlazaStaffVoteTotalForScenario = ( + expectedVotes: VoteEstimateScenarioFields | null | undefined, + pledgeEffectiveTotal: number, + scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO, +): number => getVoteEstimateForScenario(expectedVotes, scenario) ?? pledgeEffectiveTotal + +export const formatVoteEstimateRange = ( + estimates: VoteEstimateScenarioViewModel, +): string | null => { + const { pessimistic, optimistic } = estimates + if (pessimistic == null || optimistic == null) return null + return `${pessimistic.toLocaleString('pt-BR')}–${optimistic.toLocaleString('pt-BR')}` +} + +/** Staff-facing endpoints label: "Pessimista 1.200 · Otimista 1.800". */ +export const formatVoteEstimateEndpointsLabel = ( + estimates: VoteEstimateScenarioViewModel | Record, +): string | null => { + const pessimistic = estimates.pessimistic + const optimistic = estimates.optimistic + if (pessimistic == null || optimistic == null) return null + return `${voteEstimateScenarioLabels.pessimistic} ${pessimistic.toLocaleString('pt-BR')} · ${voteEstimateScenarioLabels.optimistic} ${optimistic.toLocaleString('pt-BR')}` +} + +export const hasAnyVoteEstimate = ( + estimates: VoteEstimateScenarioViewModel | Record, +): boolean => + estimates.pessimistic != null || estimates.central != null || estimates.optimistic != null + +export const formatVoteEstimateScenarioAriaLabel = ( + estimates: VoteEstimateScenarioViewModel, +): string => + VOTE_ESTIMATE_SCENARIOS.map((scenario) => { + const value = estimates[scenario] + return `${voteEstimateScenarioLabels[scenario]}: ${ + value == null ? 'não informado' : value.toLocaleString('pt-BR') + }` + }).join('; ') + +export const hasCompleteVoteEstimateRange = (estimates: VoteEstimateScenarioViewModel): boolean => + estimates.pessimistic != null && estimates.central != null && estimates.optimistic != null + +/** Coerce staff-entered scenarios into pessimistic ≤ central ≤ optimistic on save. */ +export const normalizeVoteEstimateOnSave = ( + estimates: VoteEstimateScenarioViewModel, +): VoteEstimateScenarioViewModel => { + let { pessimistic, central, optimistic } = { ...estimates } + + if (pessimistic != null && central != null && pessimistic > central) { + central = pessimistic + } + if (optimistic != null && central != null && optimistic < central) { + central = optimistic + } + if (pessimistic != null && optimistic != null && pessimistic > optimistic) { + optimistic = pessimistic + if (central != null && central < pessimistic) { + central = pessimistic + } + } + + return { pessimistic, central, optimistic } +} + +export const appendVoteEstimateScenarioToFormData = ( + formData: FormData, + prefix: 'estimatedVotes' | 'expectedVotes', + values: VoteEstimateScenarioFields, +): void => { + for (const scenario of VOTE_ESTIMATE_SCENARIOS) { + const value = values[scenario] + const fieldName = `${prefix}${scenario.charAt(0).toUpperCase()}${scenario.slice(1)}` + formData.set(fieldName, value == null ? '' : String(value)) + } +} diff --git a/src/utilities/voteEstimateScenarioFields.ts b/src/utilities/voteEstimateScenarioFields.ts new file mode 100644 index 00000000..ceaa3b03 --- /dev/null +++ b/src/utilities/voteEstimateScenarioFields.ts @@ -0,0 +1,32 @@ +import type { Field } from 'payload' + +import { canManageCampaignStaffField, canReadCampaignStaffField } from '@/utilities/campaignAccess' +import { voteEstimateScenarioLabels } from '@/utilities/voteEstimate' + +/** Shared staff-only trio for pledge estimates and plaza expected totals. */ +export const voteEstimateScenarioGroupFields = (): Field[] => [ + { + name: 'pessimistic', + type: 'number', + label: voteEstimateScenarioLabels.pessimistic, + min: 0, + }, + { + name: 'central', + type: 'number', + label: voteEstimateScenarioLabels.central, + min: 0, + }, + { + name: 'optimistic', + type: 'number', + label: voteEstimateScenarioLabels.optimistic, + min: 0, + }, +] + +export const voteEstimateScenarioGroupAccess = { + read: canReadCampaignStaffField, + create: canManageCampaignStaffField, + update: canManageCampaignStaffField, +} as const diff --git a/src/utilities/votePledgeData.ts b/src/utilities/votePledgeData.ts index af0895f1..013ca5dc 100644 --- a/src/utilities/votePledgeData.ts +++ b/src/utilities/votePledgeData.ts @@ -2,63 +2,149 @@ import type { Payload, PayloadRequest } from 'payload' import type { CampaignUser, VotePledge } from '@/payload-types' import { relationshipId, requireRelationshipId } from '@/utilities/relationship' +import { + DEFAULT_VOTE_ESTIMATE_SCENARIO, + effectivePledgeVotesForScenario, + resolvePlazaStaffVoteTotalForScenario, + toVoteEstimateScenarioViewModel, + VOTE_ESTIMATE_SCENARIOS, + type VoteEstimateScenario, + type VoteEstimateScenarioFields, + type VoteEstimateScenarioViewModel, +} from '@/utilities/voteEstimate' /** * Pledge aggregation helpers. "Effective" votes on staff surfaces are - * `estimatedVotes ?? declaredVotes`; leader-facing surfaces must NEVER receive - * estimated values or effective totals derived from them. + * `estimated[S] ?? declaredVotes` per scenario; leader-facing surfaces must + * NEVER receive estimated values or effective totals derived from them. */ +export type { VoteEstimateScenario, VoteEstimateScenarioViewModel } + +const emptyEffectiveByScenario = (): Record => ({ + pessimistic: 0, + central: 0, + optimistic: 0, +}) + export type PlazaPledgeAggregate = { declaredTotal: number - effectiveTotal: number + effectiveByScenario: Record pledgeCount: number missingEstimateCount: number } -export const emptyPlazaPledgeAggregate: PlazaPledgeAggregate = { +export const createEmptyPlazaPledgeAggregate = (): PlazaPledgeAggregate => ({ declaredTotal: 0, - effectiveTotal: 0, + effectiveByScenario: emptyEffectiveByScenario(), pledgeCount: 0, missingEstimateCount: 0, +}) + +/** Read-only zero aggregate. Never mutate — use createEmptyPlazaPledgeAggregate() when writing. */ +export const emptyPlazaPledgeAggregate: Readonly = + createEmptyPlazaPledgeAggregate() + +export type PlazaPledgeCoverageView = { + pledgeCount: number + missingEstimateCount: number + declaredTotal: number + effectiveTotal: number +} + +export const toPlazaPledgeCoverageView = ( + aggregate: PlazaPledgeAggregate, + scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO, +): PlazaPledgeCoverageView | null => { + if (aggregate.pledgeCount === 0) return null + return { + pledgeCount: aggregate.pledgeCount, + missingEstimateCount: aggregate.missingEstimateCount, + declaredTotal: aggregate.declaredTotal, + effectiveTotal: aggregate.effectiveByScenario[scenario], + } } /** Staff-facing plaza total: manual expected votes override pledge aggregate when set. */ export const resolvePlazaStaffVoteTotal = ( - expectedVotes: number | null | undefined, + expectedVotes: VoteEstimateScenarioFields | null | undefined, pledgeEffectiveTotal: number, -): number => expectedVotes ?? pledgeEffectiveTotal + scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO, +): number => resolvePlazaStaffVoteTotalForScenario(expectedVotes, pledgeEffectiveTotal, scenario) -export const sumStaffPledgeEffectiveTotal = ( - rows: ReadonlyArray<{ declaredVotes: number; estimatedVotes: number | null }>, -): number => - rows.reduce((sum, row) => sum + (row.estimatedVotes ?? row.declaredVotes), 0) +export const aggregatePlazaPledgesFromRows = ( + rows: ReadonlyArray<{ + declaredVotes: number + estimatedVotes?: VoteEstimateScenarioFields | null + }>, +): PlazaPledgeAggregate => { + const aggregate = createEmptyPlazaPledgeAggregate() + + for (const row of rows) { + const declared = row.declaredVotes ?? 0 + aggregate.declaredTotal += declared + for (const scenario of VOTE_ESTIMATE_SCENARIOS) { + aggregate.effectiveByScenario[scenario] += effectivePledgeVotesForScenario( + declared, + row.estimatedVotes, + scenario, + ) + } + aggregate.pledgeCount += 1 + if (!pledgeHasAnyEstimate(row.estimatedVotes)) aggregate.missingEstimateCount += 1 + } + + return aggregate +} export type PlazaStaffVoteRollup = { staffVoteTotal: number + staffVoteTotalByScenario: Record declaredVotesTotal: number pledgeCount: number missingEstimateCount: number } export const rollupPlazaStaffVotes = ( - plazas: ReadonlyArray<{ id: number; expectedVotes?: number | null }>, + plazas: ReadonlyArray<{ id: number; expectedVotes?: VoteEstimateScenarioFields | null }>, pledgeAggregates: Map, + scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO, ): PlazaStaffVoteRollup => { let staffVoteTotal = 0 + const staffVoteTotalByScenario = emptyEffectiveByScenario() let declaredVotesTotal = 0 let pledgeCount = 0 let missingEstimateCount = 0 for (const plaza of plazas) { const aggregate = pledgeAggregates.get(plaza.id) ?? emptyPlazaPledgeAggregate - staffVoteTotal += resolvePlazaStaffVoteTotal(plaza.expectedVotes, aggregate.effectiveTotal) + for (const key of VOTE_ESTIMATE_SCENARIOS) { + staffVoteTotalByScenario[key] += resolvePlazaStaffVoteTotal( + plaza.expectedVotes, + aggregate.effectiveByScenario[key], + key, + ) + } + staffVoteTotal += resolvePlazaStaffVoteTotal( + plaza.expectedVotes, + aggregate.effectiveByScenario[scenario], + scenario, + ) declaredVotesTotal += aggregate.declaredTotal pledgeCount += aggregate.pledgeCount missingEstimateCount += aggregate.missingEstimateCount } - return { staffVoteTotal, declaredVotesTotal, pledgeCount, missingEstimateCount } + return { + staffVoteTotal, + staffVoteTotalByScenario, + declaredVotesTotal, + pledgeCount, + missingEstimateCount, + } } +const pledgeHasAnyEstimate = (estimated: VoteEstimateScenarioFields | null | undefined): boolean => + estimated?.pessimistic != null || estimated?.central != null || estimated?.optimistic != null + /** * Staff-only aggregate over an already access-checked plaza id set. * Intentional admin bypass: callers pass plaza ids the actor may read. @@ -77,7 +163,15 @@ export const aggregatePledgesByPlaza = async ( depth: 0, limit: 0, pagination: false, - select: { plaza: true, declaredVotes: true, estimatedVotes: true }, + select: { + plaza: true, + declaredVotes: true, + estimatedVotes: { + pessimistic: true, + central: true, + optimistic: true, + }, + }, overrideAccess: true, ...(req ? { req } : {}), }) @@ -86,12 +180,18 @@ export const aggregatePledgesByPlaza = async ( const plazaID = relationshipId((doc as VotePledge).plaza) if (plazaID === null) continue const declared = (doc as VotePledge).declaredVotes ?? 0 - const estimated = (doc as VotePledge).estimatedVotes ?? null - const current = aggregates.get(plazaID) ?? { ...emptyPlazaPledgeAggregate } + const estimated = (doc as VotePledge).estimatedVotes + const current = aggregates.get(plazaID) ?? createEmptyPlazaPledgeAggregate() current.declaredTotal += declared - current.effectiveTotal += estimated ?? declared + for (const scenario of VOTE_ESTIMATE_SCENARIOS) { + current.effectiveByScenario[scenario] += effectivePledgeVotesForScenario( + declared, + estimated, + scenario, + ) + } current.pledgeCount += 1 - if (estimated === null) current.missingEstimateCount += 1 + if (!pledgeHasAnyEstimate(estimated)) current.missingEstimateCount += 1 aggregates.set(plazaID, current) } @@ -104,7 +204,7 @@ export type StaffPledgeRow = { contactName: string declaredVotes: number declaredAt: string | null - estimatedVotes: number | null + estimatedVotes: VoteEstimateScenarioViewModel estimateNote: string | null estimatedAt: string | null } @@ -130,8 +230,6 @@ export const loadPlazaPledges = async ( const leadershipIDs = [ ...new Set(pledges.docs.map((pledge) => requireRelationshipId(pledge.leadership))), ] - // Intentional admin bypass: row access was already applied to the pledges; - // this resolves display names without N+1 field-access checks. const leaderships = await payload.find({ collection: 'leadership', where: { id: { in: leadershipIDs } }, @@ -157,7 +255,7 @@ export const loadPlazaPledges = async ( contactName: nameByLeadership.get(requireRelationshipId(pledge.leadership)) ?? 'Contato', declaredVotes: pledge.declaredVotes, declaredAt: pledge.declaredAt ?? null, - estimatedVotes: pledge.estimatedVotes ?? null, + estimatedVotes: toVoteEstimateScenarioViewModel(pledge.estimatedVotes), estimateNote: pledge.estimateNote ?? null, estimatedAt: pledge.estimatedAt ?? null, })) @@ -183,11 +281,11 @@ export const loadLeaderPledges = async ( ): Promise => { const pledges = await payload.find({ collection: 'votePledge', + where: {}, depth: 0, limit: 0, pagination: false, sort: 'plaza', - where: {}, user, overrideAccess: false, }) diff --git a/tests/int/campaignPlazaAccess.int.spec.ts b/tests/int/campaignPlazaAccess.int.spec.ts index f5869ed4..44924b6a 100644 --- a/tests/int/campaignPlazaAccess.int.spec.ts +++ b/tests/int/campaignPlazaAccess.int.spec.ts @@ -153,7 +153,7 @@ describe('plaza catalog seed and per-role access', () => { }) await setPlazaExpectedVotesRecord(payload, coordinator, { plaza: plaza.id, - expectedVotes: 1200, + expectedVotes: { pessimistic: null, central: 1200, optimistic: null }, }) fixtures.touchPlaza(plaza.id) @@ -182,7 +182,7 @@ describe('plaza catalog seed and per-role access', () => { overrideAccess: false, }) expect(staffRead.priority).toBe('alta') - expect(staffRead.expectedVotes).toBe(1200) + expect(staffRead.expectedVotes?.central).toBe(1200) expect(staffRead.politicalTrend?.status).toBe('desfavoravel') expect(staffRead.politicalTrend?.recordedBy).toBeTruthy() expect(staffRead.politicalTrend?.recordedAt).toBeTruthy() @@ -254,30 +254,30 @@ describe('plaza catalog seed and per-role access', () => { const updated = await setPlazaExpectedVotesRecord(payload, coordinator, { plaza: administered.id, - expectedVotes: 2500, + expectedVotes: { pessimistic: null, central: 2500, optimistic: null }, }) - expect(updated.expectedVotes).toBe(2500) + expect(updated.expectedVotes?.central).toBe(2500) fixtures.touchPlaza(administered.id) const advisorUpdated = await setPlazaExpectedVotesRecord(payload, advisor, { plaza: administered.id, - expectedVotes: 3000, + expectedVotes: { pessimistic: null, central: 3000, optimistic: null }, }) - expect(advisorUpdated.expectedVotes).toBe(3000) + expect(advisorUpdated.expectedVotes?.central).toBe(3000) fixtures.touchPlaza(administered.id) await expect( setPlazaExpectedVotesRecord(payload, advisor, { plaza: outside.id, - expectedVotes: 100, + expectedVotes: { pessimistic: null, central: 100, optimistic: null }, }), ).rejects.toThrow() const cleared = await setPlazaExpectedVotesRecord(payload, coordinator, { plaza: administered.id, - expectedVotes: null, + expectedVotes: { pessimistic: null, central: null, optimistic: null }, }) - expect(cleared.expectedVotes).toBeNull() + expect(cleared.expectedVotes?.central ?? null).toBeNull() fixtures.touchPlaza(administered.id) }) diff --git a/tests/int/campaignVotePledge.int.spec.ts b/tests/int/campaignVotePledge.int.spec.ts index b6c6b29a..68a0b1ea 100644 --- a/tests/int/campaignVotePledge.int.spec.ts +++ b/tests/int/campaignVotePledge.int.spec.ts @@ -81,10 +81,10 @@ describe('vote pledges (declared by the leader, estimated by staff)', () => { const estimated = await estimateVotesRecord(payload, advisor, { pledge: pledge.id, - estimatedVotes: 120, + estimatedVotes: { pessimistic: null, central: 120, optimistic: null }, estimateNote: 'Base histórica indica menos da metade.', }) - expect(estimated.estimatedVotes).toBe(120) + expect(estimated.estimatedVotes?.central).toBe(120) expect(estimated.estimatedBy).toBeTruthy() // Leader read path (access enforced): estimated fields must be stripped. @@ -121,7 +121,7 @@ describe('vote pledges (declared by the leader, estimated by staff)', () => { await payload.update({ collection: 'votePledge', id: pledge.id, - data: { estimatedVotes: 9_999 } as never, + data: { estimatedVotes: { central: 9_999 } } as never, depth: 0, user: account, overrideAccess: false, @@ -133,7 +133,9 @@ describe('vote pledges (declared by the leader, estimated by staff)', () => { depth: 0, overrideAccess: true, }) - expect(raw.estimatedVotes ?? null).toBeNull() + expect(raw.estimatedVotes?.central ?? null).toBeNull() + expect(raw.estimatedVotes?.pessimistic ?? null).toBeNull() + expect(raw.estimatedVotes?.optimistic ?? null).toBeNull() }) it('scopes an advisor to pledges of administered plazas only', async () => { @@ -149,7 +151,7 @@ describe('vote pledges (declared by the leader, estimated by staff)', () => { await expect( estimateVotesRecord(payload, outsideAdvisor, { pledge: pledge.id, - estimatedVotes: 10, + estimatedVotes: { pessimistic: null, central: 10, optimistic: null }, estimateNote: null, }), ).rejects.toThrow() @@ -177,7 +179,7 @@ describe('vote pledges (declared by the leader, estimated by staff)', () => { fixtures.own('votePledge', firstPledge.id) await estimateVotesRecord(payload, advisor, { pledge: firstPledge.id, - estimatedVotes: 80, + estimatedVotes: { pessimistic: null, central: 80, optimistic: null }, estimateNote: null, }) @@ -197,7 +199,7 @@ describe('vote pledges (declared by the leader, estimated by staff)', () => { const aggregate = aggregates.get(plaza.id) expect(aggregate?.pledgeCount).toBe(2) expect(aggregate?.declaredTotal).toBe(240) - expect(aggregate?.effectiveTotal).toBe(120) + expect(aggregate?.effectiveByScenario.central).toBe(120) expect(aggregate?.missingEstimateCount).toBe(1) expect(secondPledge.declaredVotes).toBe(40) }) diff --git a/tests/int/plazaPageData.int.spec.ts b/tests/int/plazaPageData.int.spec.ts index c2c78814..72ec940c 100644 --- a/tests/int/plazaPageData.int.spec.ts +++ b/tests/int/plazaPageData.int.spec.ts @@ -6,6 +6,7 @@ import { beforeAll, describe, expect, it } from 'vitest' import config from '@/payload.config' import { loadPlazaListPageBundle } from '@/utilities/plazaPageData' +import { aggregatePledgesByPlaza, rollupPlazaStaffVotes } from '@/utilities/votePledgeData' import { installCampaignFixtures } from '../helpers/campaignFixtures' @@ -41,7 +42,7 @@ describe('loadPlazaListPageBundle', () => { await payload.update({ collection: 'plaza', id: plaza.id, - data: { expectedVotes: 1_500 }, + data: { expectedVotes: { pessimistic: null, central: 1_500, optimistic: null } }, depth: 0, overrideAccess: true, }) @@ -56,14 +57,24 @@ describe('loadPlazaListPageBundle', () => { leadership: leadership.id, plaza: plaza.id, declaredVotes: 80, - estimatedVotes: 120, + estimatedVotes: { pessimistic: null, central: 120, optimistic: null }, }) + const refreshedPlaza = await payload.findByID({ + collection: 'plaza', + id: plaza.id, + depth: 0, + overrideAccess: true, + }) + const aggregates = await aggregatePledgesByPlaza(payload, [plaza.id]) + const rollup = rollupPlazaStaffVotes([refreshedPlaza], aggregates) + expect(rollup.staffVoteTotal).toBe(1_500) + const bundle = await loadPlazaListPageBundle(payload, coordinator, { q: plaza.name }) expect(bundle.overview).not.toBeNull() expect(bundle.mapBundle).not.toBeNull() - expect(bundle.overview!.staffVoteTotal).toBe(1_500) + expect(bundle.overview!.staffVoteTotalByScenario.central).toBeGreaterThanOrEqual(1_500) }) it('keeps advisor access and applies URL filters on top', async () => { diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts index e8623a5d..54bdfa7c 100644 --- a/tests/unit/campaignComponents.unit.spec.ts +++ b/tests/unit/campaignComponents.unit.spec.ts @@ -11,9 +11,10 @@ import { TseZoneBadge } from '@/components/campaign/TseZoneBadge' import { Progress } from '@/components/ui/Progress' import { Toggle } from '@/components/ui/Toggle' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup' -import type { PlazaAdvisorSummary, PlazaListViewModel } from '@/utilities/plazaViewModels' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' -import { emptyPlazaPledgeAggregate } from '@/utilities/votePledgeData' +import type { PlazaAdvisorSummary, PlazaListViewModel } from '@/utilities/plazaViewModels' +import { toVoteEstimateScenarioViewModel } from '@/utilities/voteEstimate' +import { createEmptyPlazaPledgeAggregate } from '@/utilities/votePledgeData' const noopListFormAction = async ( _state: CampaignFormActionState, @@ -23,7 +24,6 @@ const noopListFormAction = async ( const plazaListDefaultProps = { isCoordinator: false, advisorOptions: [], - expectedVotesFormAction: noopListFormAction, trendFormAction: noopListFormAction, advisorsFormAction: noopListFormAction, } @@ -133,12 +133,12 @@ describe('campaign visual foundation', () => { advisorIDs: [advisor.id], priority: 'alta', lastUpdateAt: null, - expectedVotes: 1500, + expectedVotes: { pessimistic: null, central: 1500, optimistic: null }, politicalTrendStatus: 'favoravel', politicalTrendNote: null, pledges: { declaredTotal: 1200, - effectiveTotal: 1200, + effectiveByScenario: { pessimistic: 1200, central: 1200, optimistic: 1200 }, pledgeCount: 2, missingEstimateCount: 1, }, @@ -155,10 +155,10 @@ describe('campaign visual foundation', () => { advisorIDs: [], priority: 'normal', lastUpdateAt: null, - expectedVotes: null, + expectedVotes: toVoteEstimateScenarioViewModel(null), politicalTrendStatus: null, politicalTrendNote: null, - pledges: { ...emptyPlazaPledgeAggregate }, + pledges: createEmptyPlazaPledgeAggregate(), }, ] @@ -175,13 +175,51 @@ describe('campaign visual foundation', () => { expect(html).toContain('Chapada Diamantina') expect(html).toContain('href="/campanha/pracas/seabra"') expect(html).toContain('1.500') - expect(html).toContain('Nas lideranças: 1.200') + expect(html).toContain('Cenários de estimativa') expect(html).toContain('Prioritária') expect(html).toContain('Coberta') expect(html).toContain('Sem assessor') expect(html).toContain('Tendência') }) + it('hides leadership coverage subline when pledges only have declared votes', () => { + const html = renderToStaticMarkup( + createElement(PlazaList, { + plazas: [ + { + id: 3, + name: 'Itaberaba', + slug: 'itaberaba', + kind: 'municipio', + city: 'Itaberaba', + region: 'Centro Norte', + ibgeCode: '2914703', + zoneNumber: null, + advisorIDs: [], + priority: 'normal', + lastUpdateAt: null, + expectedVotes: toVoteEstimateScenarioViewModel(null), + politicalTrendStatus: null, + politicalTrendNote: null, + pledges: { + declaredTotal: 800, + effectiveByScenario: { pessimistic: 800, central: 800, optimistic: 800 }, + pledgeCount: 1, + missingEstimateCount: 1, + }, + }, + ], + advisorNamesById: new Map(), + isStaffView: true, + ...plazaListDefaultProps, + }), + ) + + expect(html).toContain('Itaberaba') + expect(html).not.toContain('>Nas lideranças') + expect(html).not.toContain('Declarado nas lideranças') + }) + it('hides staff-only pledge and coverage columns from the leader view', () => { const html = renderToStaticMarkup( createElement(PlazaList, { @@ -198,10 +236,10 @@ describe('campaign visual foundation', () => { advisorIDs: [], priority: 'alta', lastUpdateAt: null, - expectedVotes: null, + expectedVotes: toVoteEstimateScenarioViewModel(null), politicalTrendStatus: null, politicalTrendNote: null, - pledges: { ...emptyPlazaPledgeAggregate }, + pledges: createEmptyPlazaPledgeAggregate(), }, ], advisorNamesById: new Map(), diff --git a/tests/unit/plazaStaffVoteTotal.unit.spec.ts b/tests/unit/plazaStaffVoteTotal.unit.spec.ts index 461c2f56..2fd767ca 100644 --- a/tests/unit/plazaStaffVoteTotal.unit.spec.ts +++ b/tests/unit/plazaStaffVoteTotal.unit.spec.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from 'vitest' import { - emptyPlazaPledgeAggregate, + createEmptyPlazaPledgeAggregate, resolvePlazaStaffVoteTotal, rollupPlazaStaffVotes, } from '@/utilities/votePledgeData' describe('resolvePlazaStaffVoteTotal', () => { - it('falls back to pledge effective total when expectedVotes is null', () => { + it('falls back to pledge effective total when expectedVotes central is null', () => { expect(resolvePlazaStaffVoteTotal(null, 420)).toBe(420) }) - it('uses expectedVotes when set, ignoring pledge aggregate', () => { - expect(resolvePlazaStaffVoteTotal(1500, 420)).toBe(1500) + it('uses expectedVotes central when set, ignoring pledge aggregate', () => { + expect(resolvePlazaStaffVoteTotal({ central: 1500 }, 420)).toBe(1500) }) it('returns zero when both expectedVotes and pledges are empty', () => { @@ -23,19 +23,38 @@ describe('resolvePlazaStaffVoteTotal', () => { describe('rollupPlazaStaffVotes', () => { it('sums per-plaza staff totals and pledge metadata', () => { const aggregates = new Map([ - [1, { ...emptyPlazaPledgeAggregate, effectiveTotal: 100, declaredTotal: 80, pledgeCount: 2, missingEstimateCount: 1 }], - [2, { ...emptyPlazaPledgeAggregate, effectiveTotal: 50, declaredTotal: 50, pledgeCount: 1, missingEstimateCount: 0 }], + [ + 1, + { + ...createEmptyPlazaPledgeAggregate(), + effectiveByScenario: { pessimistic: 80, central: 100, optimistic: 120 }, + declaredTotal: 80, + pledgeCount: 2, + missingEstimateCount: 1, + }, + ], + [ + 2, + { + ...createEmptyPlazaPledgeAggregate(), + effectiveByScenario: { pessimistic: 40, central: 50, optimistic: 60 }, + declaredTotal: 50, + pledgeCount: 1, + missingEstimateCount: 0, + }, + ], ]) expect( rollupPlazaStaffVotes( [ { id: 1, expectedVotes: null }, - { id: 2, expectedVotes: 200 }, + { id: 2, expectedVotes: { central: 200 } }, ], aggregates, ), ).toEqual({ staffVoteTotal: 300, + staffVoteTotalByScenario: { pessimistic: 120, central: 300, optimistic: 180 }, declaredVotesTotal: 130, pledgeCount: 3, missingEstimateCount: 1, diff --git a/tests/unit/voteEstimate.unit.spec.ts b/tests/unit/voteEstimate.unit.spec.ts new file mode 100644 index 00000000..8758dbf0 --- /dev/null +++ b/tests/unit/voteEstimate.unit.spec.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' + +import { + effectivePledgeVotesForScenario, + formatVoteEstimateEndpointsLabel, + getVoteEstimateOrderViolation, + normalizeVoteEstimateOnSave, + resolvePlazaStaffVoteTotalForScenario, +} from '@/utilities/voteEstimate' +import { + createEmptyPlazaPledgeAggregate, + resolvePlazaStaffVoteTotal, + rollupPlazaStaffVotes, +} from '@/utilities/votePledgeData' + +describe('getVoteEstimateOrderViolation', () => { + it('returns null when pessimistic ≤ central ≤ optimistic', () => { + expect( + getVoteEstimateOrderViolation({ pessimistic: 100, central: 200, optimistic: 300 }), + ).toBeNull() + }) + + it('flags central when it breaks order', () => { + expect(getVoteEstimateOrderViolation({ pessimistic: 300, central: 100 })).toBe('central') + }) +}) + +describe('normalizeVoteEstimateOnSave', () => { + it('raises central when pessimistic exceeds it', () => { + expect( + normalizeVoteEstimateOnSave({ pessimistic: 800, central: 700, optimistic: 900 }), + ).toEqual({ pessimistic: 800, central: 800, optimistic: 900 }) + }) + + it('lowers central when optimistic is below it', () => { + expect( + normalizeVoteEstimateOnSave({ pessimistic: 500, central: 700, optimistic: 600 }), + ).toEqual({ pessimistic: 500, central: 600, optimistic: 600 }) + }) + + it('leaves ordered values unchanged', () => { + expect( + normalizeVoteEstimateOnSave({ pessimistic: 100, central: 200, optimistic: 300 }), + ).toEqual({ pessimistic: 100, central: 200, optimistic: 300 }) + }) +}) + +describe('effectivePledgeVotesForScenario', () => { + it('uses declared when scenario estimate is missing', () => { + expect(effectivePledgeVotesForScenario(50, { central: null }, 'central')).toBe(50) + }) + + it('uses scenario estimate when present', () => { + expect( + effectivePledgeVotesForScenario( + 50, + { pessimistic: 30, central: 40, optimistic: 60 }, + 'pessimistic', + ), + ).toBe(30) + }) +}) + +describe('resolvePlazaStaffVoteTotal', () => { + it('falls back to pledge effective total when expected central is null', () => { + expect(resolvePlazaStaffVoteTotal(null, 420)).toBe(420) + }) + + it('uses expected central when set', () => { + expect(resolvePlazaStaffVoteTotal({ central: 1500 }, 420)).toBe(1500) + }) + + it('resolves per scenario independently', () => { + expect( + resolvePlazaStaffVoteTotalForScenario( + { pessimistic: 100, central: 200, optimistic: 300 }, + 420, + 'optimistic', + ), + ).toBe(300) + }) +}) + +describe('rollupPlazaStaffVotes', () => { + it('sums per-plaza staff totals and pledge metadata', () => { + const aggregates = new Map([ + [ + 1, + { + ...createEmptyPlazaPledgeAggregate(), + effectiveByScenario: { pessimistic: 80, central: 100, optimistic: 120 }, + declaredTotal: 80, + pledgeCount: 2, + missingEstimateCount: 1, + }, + ], + [ + 2, + { + ...createEmptyPlazaPledgeAggregate(), + effectiveByScenario: { pessimistic: 40, central: 50, optimistic: 60 }, + declaredTotal: 50, + pledgeCount: 1, + missingEstimateCount: 0, + }, + ], + ]) + expect( + rollupPlazaStaffVotes( + [ + { id: 1, expectedVotes: null }, + { id: 2, expectedVotes: { central: 200 } }, + ], + aggregates, + ), + ).toEqual({ + staffVoteTotal: 300, + staffVoteTotalByScenario: { pessimistic: 120, central: 300, optimistic: 180 }, + declaredVotesTotal: 130, + pledgeCount: 3, + missingEstimateCount: 1, + }) + }) +}) + +describe('formatVoteEstimateEndpointsLabel', () => { + it('labels pessimista and otimista endpoints', () => { + expect( + formatVoteEstimateEndpointsLabel({ + pessimistic: 1200, + central: 1500, + optimistic: 1800, + }), + ).toBe('Pessimista 1.200 · Otimista 1.800') + }) + + it('returns null when an endpoint is missing', () => { + expect( + formatVoteEstimateEndpointsLabel({ + pessimistic: 1200, + central: 1500, + optimistic: null, + }), + ).toBeNull() + }) +}) diff --git a/vitest.setup.ts b/vitest.setup.ts index f60b1af9..b3a11617 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -7,5 +7,10 @@ import { assertTestDatabase } from './tests/helpers/assertTestDatabase' loadEnv({ path: '.env.test', override: true }) +// Optional local override when the default Docker port is occupied (e.g. mapped to 5433). +if (process.env.TEQO_TEST_DATABASE_URL) { + process.env.DATABASE_URL = process.env.TEQO_TEST_DATABASE_URL +} + // Fail closed: refuse to run the (destructive) suite against a non-test database. assertTestDatabase(process.env.DATABASE_URL)