Deliver A9 plaza expectedVotes and register A9+ follow-up.

Add staff-only expectedVotes with migration, edit flow, and expectedVotes ?? pledge fallback across map, overview, and dashboard; document delivery and the A9+ loader dedup fill-in on the roadmap.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-21 11:02:11 -03:00
parent be1106f887
commit b333609726
29 changed files with 10874 additions and 74 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **A9 entregue em código (2026-07-21; deploy pendente com remodelagem):** `plaza.expectedVotes` staff-only, fallback `expectedVotes ?? effectiveTotal` em mapa 2026/overview/dashboard, UI `/editar` + leitura lista/detalhe; migration `20260721_133444_add_plaza_expected_votes`. Plano: [`estimativa-votos-praca.md`](../../../docs/plans/estimativa-votos-praca.md). Fill-in pós-`/simplify`: **A9+** [`escala-dry-pos-a9.md`](../../../docs/plans/escala-dry-pos-a9.md) (deduplicar `aggregatePledgesByPlaza` na lista). **B9** desbloqueado.
- **REMODELAGEM ENTREGUE EM CÓDIGO (2026-07-21; deploy pendente — migração `20260721_020109_remodel_plazas` é destrutiva para a vertical campanha, revisar SQL antes do build de produção). Suítes verdes: unit 187, int 306 (incl. assimetria declarado×estimado, access por papel, workflow de demandas), e2e 9. Critique/polish visual fino registrado como R6 no roadmap.** Feedback da coordenação invalidou o modelo de núcleo abstrato. Decisões travadas: 436 Praças pré-definidas (415 municípios + 19 zonas de Salvador + 2 de Camaçari; zonas compartilhadas cortadas na divisa municipal); código `plaza`; roles renomeados para `coordinator`/`advisor`/`leader` (labels "Coordenador Geral"/"Assessor"/"Liderança"); assessor vê só as Praças que administra; liderança = 1 registro por pessoa com N Praças e N organizações; votos por liderança×Praça (`declaredVotes` visível à liderança, `estimatedVotes` staff-only); demandas com workflow assessor→coordenador geral + comprovantes staff-only; tendência política manual (staff); comparativo multi-candidato/multi-ano por Praça; mapa divergente vermelho↔branco↔azul. Reset dos dados de campanha em produção (site público intocado). Plano-mestre: [`docs/plans/remodelagem-pracas.md`](../../../docs/plans/remodelagem-pracas.md). As entradas abaixo descrevem a era Núcleos — padrões de infra (locks, transações, consent por chave, shells, mapa, dados eleitorais) continuam válidos; superfícies e modelo de Núcleo foram substituídos.
- **MVP Ciclo 1 entregue e enviado em `main` (2026-07-18):** ondas 1–8 (auth/RBAC, domínio+UI de Núcleo/Liderança/Inteligência/Atualizações/Convites, dashboard, hardening) + refactors de composição Next.js, primitivos compartilhados, locks PostgreSQL, fixtures E2E e migration consolidada `20260718_010733_consolidate_campaign_schema`.
- **Ciclo 2 entregue, mesclado e deployado (2026-07-18):** A1 território multi-município/bairro, A3 baseline TSE 2022 (modelo + import), B1 overview da lista, C1 compartilhar página e D1 PWA — detalhes no item datado abaixo.

View file

@ -1,6 +1,6 @@
# Edição rápida na lista de Praças
Status: rascunho
Status: rascunho (A9 entregue — pronto para implementar)
Atualizado em: 2026-07-21
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B9)
Impeccable: B — encaixe em `PlazaList` / cards mobile em `/campanha/pracas` (sem rota nova)

View file

@ -0,0 +1,101 @@
# Escala e DRY pós-A9 (loader da lista de Praças)
Status: registrado no roadmap (Fase 1 pendente)
Atualizado em: 2026-07-21 (`capture-review-debts` pós-A9 + `/simplify`)
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, fill-in **A9+** pós-A9)
Impeccable: A — N/A (sem superfície UI; otimização de loader)
Appetite: ~0,5–1 dia eng (1 fase; PR único)
Responsável: —
## Contexto
O A9 ([estimativa-votos-praca.md](estimativa-votos-praca.md)) entrega `plaza.expectedVotes` (staff-only), `setPlazaExpectedVotes`, fallback `expectedVotes ?? effectiveTotal` em mapa/overview/dashboard, UI em `/editar` + leitura na lista/detalhe, migration `20260721_133444_add_plaza_expected_votes`, e helpers `resolvePlazaStaffVoteTotal` / `rollupPlazaStaffVotes` / `sumStaffPledgeEffectiveTotal` em `votePledgeData.ts`.
Uma passagem `/simplify` pós-entrega já limpou o que cabia em cleanup: form `optionalIntegerFormValue` único; remoção de `hasStaffVoteData`; `StaffPlazaVotesDisplay` compartilhado; JSDoc da métrica 2026; testes unit do rollup.
O revisor de **performance** marcou como **maior que simplify** o follow-up abaixo. Sem registro, cada abertura de `/campanha/pracas` (staff) dispara **três** agregações de pledges sobre conjuntos sobrepostos.
1. **Query triplicada de pledges na página `/campanha/pracas`.** `page.tsx` chama em paralelo `loadPlazaListPageData` (pledges da página atual), `loadPlazaListOverviewData` (pledges de **todo** o conjunto filtrado) e `loadPlazaMapBundle` (pledges de **todas** as Praças do escopo do mapa). Cada loader invoca `aggregatePledgesByPlaza` de forma independente — padrão análogo ao E6 F1 em núcleos ([escala-dry-pos-e1.md](escala-dry-pos-e1.md)), mas na vertical Praças pós-remodelagem.
**Já resolvido no simplify/critique (não reabrir):** parsing duplo do form `expectedVotes`; `hasStaffVoteData` derivado de `staffVoteTotal > 0`; `rollupPlazaStaffVotes` + `sumStaffPledgeEffectiveTotal`; `StaffPlazaVotesDisplay`; assinatura estreita de `resolvePlazaStaffVoteTotal`; testes unit do rollup.
**Explicitamente fora (triage `capture-review-debts` 2026-07-21):** semântica lista (só `expectedVotes` manual) vs mapa/overview (fallback) — decisão de produto A9; merge de forms Estratégia + Votos no `/editar`; grid cosmético do `PlazaStrategyCard`; `loadPlazaPledges` em abas distintas do detalhe (uma aba por request); débitos adiados/não escopo do plano A9 (nota/autor, chip auto-sugerir, histórico, filtro `?expectedVotes=`); critique Impeccable formal por superfície → **R6**.
## Objetivos
- Abrir `/campanha/pracas` com filtro amplo não executa **três** `aggregatePledgesByPlaza` independentes quando um mapa de agregados compartilhado basta.
- Overview, mapa 2026 e coluna da página reutilizam o mesmo `Map<plazaId, PlazaPledgeAggregate>` (e, onde os filtros coincidirem, o mesmo `find` de Praças).
- Paridade funcional: KPIs, métrica 2026 e sublinha “Nas lideranças” permanecem idênticos ao comportamento pós-A9.
- Guardrails: sem migration, sem collection, sem Consent, sem server action nova; access inalterado (`overrideAccess: false` nos reads de Praça; agregado de pledges continua admin-bypass intencional com ids já access-checked).
## Decisões travadas
- **Um fill-in A9+, uma fase (F1).** Mesmo racional de A7/C6/A8+: um slug de plano, PR único. **Rejeitado:** item de roadmap separado `A10` — follow-up pós-entrega, não feature nova.
- **Dependência dura de A9.** Só faz sentido com `expectedVotes`, `rollupPlazaStaffVotes` e os três loaders já no produto; não reabre escopo de schema ou UI de edição.
- **F1 não remove overview nem mapa.** Otimiza como os dados chegam; paridade com a plano A9 permanece. **Rejeitado:** unificar lista+overview num único `find` unpaginado sem validar regressão de paginação/`depth`.
- **Cortável** se a lista com mapa permanecer aceitável em campo com dezenas de assessores; vira não-cortável quando B7 (filtro) + mapa sempre visível multiplicarem o conjunto filtrado (436 Praças para coordenador).
- **i18n e naming** (AGENTS.md): identificadores em inglês (`loadPlazaListPageBundle`, `PlazaListPledgeAggregates`); strings visíveis inalteradas em pt-BR.
## Questões em aberto
- **Bundle único vs compartilhar só o mapa de pledges?** **Opções:** (A) `loadPlazaListPageBundle` retorna `{ list, overview, mapInputs, pledgeAggregates }`; (B) só deduplicar `aggregatePledgesByPlaza` num helper `loadPlazaPledgeAggregatesForListPage` consumido pelos três loaders. **Recomendação:** (A) se os três `find` de Praças puderem compartilhar escopo sem duplicar lógica de filtro; senão (B) como primeiro passo — ganho imediato no hot path de pledges.
- **Overview unpaginado vs página paginada?** **Recomendação:** manter dois `find` de Praças (conjunto filtrado inteiro vs página) — o gargalo registrado é o **terceiro** agregado de pledges, não a paginação em si; unificar `find` só se profiling mostrar custo dominante em Praças, não em pledges.
## Abordagem proposta
```mermaid
flowchart TD
A9["A9 expectedVotes ✓"] --> F1
F1["Fase 1 — loadPlazaListPageBundle<br/>1× aggregatePledgesByPlaza"]
F1 --> List["PlazaList página"]
F1 --> Overview["PlazaListOverview KPIs"]
F1 --> Map["PlazaMapBundle 2026"]
B7["B7 mapa filtrado"] -.mesmo bundle.-> F1
```
### Fase 1 — Loader compartilhado da lista de Praças
- Introduzir `loadPlazaListPageBundle` (ou equivalente em `plazaPageData.ts` + ajuste fino em `plazaMapData.ts`) que:
- Resolve filtros uma vez (`parsePlazaListParams` / `buildPlazaListWhere`).
- Carrega Praças do escopo necessário (página + overview + mapa) com o mínimo de round-trips.
- Chama `aggregatePledgesByPlaza` **uma vez** sobre a união de ids necessários (ou sobre o conjunto filtrado inteiro se mais simples e dentro do appetite).
- Expõe `pledgeAggregates` reutilizado por `toPlazaListViewModel`, `rollupPlazaStaffVotes` e `loadPlazaMapBundle` (refatorar para aceitar agregados pré-computados ou extrair helper puro).
- Atualizar `src/app/(campaign)/campanha/(app)/pracas/page.tsx` para consumir o bundle.
- Testes: int ou unit do bundle — paridade de `staffVoteTotal` e métrica 2026 antes/depois; advisor scope inalterado.
**Migration:** nenhuma.
## Dependências
- **Dura:** A9 [estimativa-votos-praca.md](estimativa-votos-praca.md) — campo, agregadores e superfícies (merge em `main`).
- **Suave:** B7 [mapa-pracas-filtrado.md](mapa-pracas-filtrado.md) — mesmo URL de filtro; F1 facilita B7 ao centralizar o where.
- Reusa: `aggregatePledgesByPlaza`, `rollupPlazaStaffVotes`, `resolvePlazaStaffVoteTotal`, precedente E6 F1 em [escala-dry-pos-e1.md](escala-dry-pos-e1.md).
## Não escopo
- SQL aggregate de pledges (`COUNT(*) FILTER`) — volume ainda baixo; reavaliar no 3º hot path ou com evidência de latency.
- Unificar `loadPlazaPledges` entre abas Overview/Lideranças no detalhe — defer (uma aba por request).
- Edição inline na lista → **B9** ([edicao-rapida-lista-pracas.md](edicao-rapida-lista-pracas.md)).
- Merge de forms no `/editar` — fora do triage.
- Impeccable critique das superfícies A9 → **R6**.
## Rabbit holes
- **Unificar lista+overview+mapa num único `find` unpaginado de 436 Praças sempre.** Mitigação: F1 foca em deduplicar **pledges**; `find` de Praças permanece paginado onde já é.
- **Cache cross-request de agregados.** Mitigação: bundle por request RSC apenas; sem Redis/memória global neste fill-in.
## Adiado com gatilho
- **SQL aggregate espelhando `buildPlazaListWhere`.** Revisitar quando profiling mostrar `aggregatePledgesByPlaza` dominando TTFB com filtros amplos + mapa sempre montado.
- **Compartilhar bundle com dashboard geral.** Revisitar se o dashboard passar a carregar mapa+lista na mesma rota.
## Referências
- `docs/roadmap.md` (Trilha A, A9 entregue + fill-in A9+)
- `docs/plans/estimativa-votos-praca.md` — plano pai A9
- `docs/plans/escala-dry-pos-e1.md` — precedente E6 F1 (lista+overview núcleos)
- `src/app/(campaign)/campanha/(app)/pracas/page.tsx` — `Promise.all` dos três loaders
- `src/utilities/plazaPageData.ts` — `loadPlazaListPageData`, `loadPlazaListOverviewData`
- `src/utilities/plazaMapData.ts` — `loadPlazaMapBundle`, métrica 2026
- `src/utilities/votePledgeData.ts` — `aggregatePledgesByPlaza`, `rollupPlazaStaffVotes`
- AGENTS.md — naming EN/pt-BR; access staff; sem PII

View file

@ -1,6 +1,6 @@
# Estimativa de votos da Praça (total esperado)
Status: rascunho
Status: entregue 2026-07-21 (branch; deploy pendente com remodelagem)
Atualizado em: 2026-07-21
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha A, item A9)
Impeccable: B — campo + migration + action + encaixe em `/editar` / detalhe / lista (leitura); edição inline na lista = B9
@ -109,9 +109,16 @@ Componentes:
- **Aviso vs `voteGoals.minimum`.** Revisitar após primeiras semanas com totais preenchidos.
- **Auto-sugerir `expectedVotes` a partir dos pledges (opt-in chip).** Revisitar se muitos totais ≈ soma das lideranças.
## Pós-entrega (`/simplify`)
Débitos **maiores que cleanup** da sessão de entrega → fill-in **A9+** [escala-dry-pos-a9.md](escala-dry-pos-a9.md) (loader compartilhado da lista — deduplicar `aggregatePledgesByPlaza` na página `/campanha/pracas`).
**Já resolvido no simplify (não reabrir):** form `expectedVotes`; `rollupPlazaStaffVotes` / `sumStaffPledgeEffectiveTotal`; `StaffPlazaVotesDisplay`; remoção de `hasStaffVoteData`; testes unit do rollup.
## Referências
- `docs/roadmap.md` (Trilha A / A9 → B9)
- `docs/plans/escala-dry-pos-a9.md` — fill-in A9+ (loader lista)
- `src/collections/Plaza.ts` — `voteGoals`, access staff
- `src/utilities/votePledgeData.ts` — `effectiveTotal`
- `src/utilities/plazaMapData.ts` — métrica 2026

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-21 (Pixel Meta entregue; B10 click-nav; B11 escala % mapa; A9→B9; B8; Fill-in filtros; B7)
Atualizado em: 2026-07-21 (A9 entregue; A9+ loader lista; B9 desbloqueado; Pixel Meta)
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).
@ -68,7 +68,7 @@ flowchart TD
TSE2026["TSE candidaturas 2026<br/>(após 15/08)"] --> A6["A6 Dobradinha 2026<br/>(reenquadrada p/ Praça)"]
R4 -.baseline por Praça.-> A6
R2 --> D2["D2 Push + sino"]
R2 --> A9["A9 Estimativa votos da Praça"]
R2 --> A9["A9 Estimativa votos ✓"]
A9 --> B9["B9 Edição rápida lista Praças"]
R2 -.-> B7["B7 Mapa filtrado pela lista"]
R2 -.-> B8["B8 Polígonos Praças-zona<br/>(SSA/CMS)"]
@ -81,11 +81,11 @@ flowchart TD
C2prod --> C5["C5 GOTV (validar)"]
```
Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): **B7**, **B10**, **B11**, fill-ins da lista. **A9** bloqueia **B9** (seta dura).
Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): **B7**, **B10**, **B11**, fill-ins da lista. **A9** entregue — **B9** desbloqueado (seta dura A9 → B9).
### Sequência por janela
**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; **A9** estimativa de votos da Praça → **B9** edição rápida na lista (Assessores / Tendência / total esperado); **B7** mapa filtrado, **B10** hover+click-nav e **B11** escala % dos válidos em paralelo; R6 critique/polish; Onda 0 jurídica em paralelo.
**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos da Praça~~ entregue; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; **B9** edição rápida na lista (Assessores / Tendência / total esperado); **B7** mapa filtrado, **B10** hover+click-nav e **B11** escala % dos válidos em paralelo; 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; D2 se sobrar folga.
@ -95,15 +95,16 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2):
### 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** (sem total esperado da Praça, lista/mapa mentem onde não há liderança — schema do modelo operacional).
**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).
**Cortes seguros** (se o prazo apertar, nesta ordem): 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; B6; B7 (mapa ainda mostra escopo de access sem filtro de UI); **B11** (mapa continua em escala absoluta); **B10** (mapa legível sem hover); **B9** (com A9, edição do total fica só no `/editar`); **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); débitos/fill-ins.
**Cortes seguros** (se o prazo apertar, nesta ordem): 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; B6; B7 (mapa ainda mostra escopo de access sem filtro de UI); **B11** (mapa continua em escala absoluta); **B10** (mapa legível sem hover); **B9** (edição do total fica só no `/editar`); **B8** (F2 polígonos; manter F1 bairros na Praça se já entregue — mapa continua agregado no município); débitos/fill-ins.
## Já entregue (resumo)
- **Era Núcleos (2026-07-15 → 2026-07-20)** — MVP + Ciclo 2 (auth `campaignUser`, território A1/A2, baseline TSE A3/A4, overview B1, share C1, PWA D1, geometrias B2, Leaflet B3), C2 apoiadores (eng.), C3 agenda, C6–C11 escala, E1+E3 metas/estratégia, E2 série TSE 2014/2018/2022, A5 conversão/classificação/alavancagem/mobilização, A7 F1–F2, A8 perfis IBGE, fill-ins (reset senha/perfil, visitados recentes, Field Desk polish). Infra e padrões (locks, transações, consent por chave, shells, mapa, dados eleitorais) **são reaproveitados pela remodelagem**; as superfícies e o modelo de Núcleo são substituídos.
- **Plataforma** — local Postgres + guards, migrations baselined, posts/tags do site público com cache `posts`, Onda 0 textos provisórios + `/privacidade`.
- **Site público (2026-07-21)** — **Pixel do Meta nos abaixo-assinados** (`tracking.facebookPixelId` no admin `petition`, `PageView`/`Lead` na página pública via `MetaPixel` + `trackMetaLead`; migration `20260721_133531_add_petition_facebook_pixel_id`) — [plano](plans/pixel-meta-abaixo-assinado.md).
- **A9 (2026-07-21)** — **Estimativa de votos da Praça** (`plaza.expectedVotes` staff-only; fallback `expectedVotes ?? effectiveTotal` em mapa 2026/overview/dashboard; UI `/editar` + leitura lista/detalhe; migration `20260721_133444_add_plaza_expected_votes`) — [plano](plans/estimativa-votos-praca.md). Fill-in pós-`/simplify`: **A9+** [escala-dry-pos-a9.md](plans/escala-dry-pos-a9.md).
## Supersedidos pela remodelagem (2026-07-20)
@ -124,18 +125,17 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2):
## Próximos — sobreviventes e pós-remodelagem
- **A6** dobradinha 2026 automática quando o TSE publicar candidaturas · gatilho externo: pós-15/08 · reenquadrar para Praça · [plano](plans/insight-dobradinha-2026.md)
- **A9** Estimativa de votos da Praça (`expectedVotes` — total esperado staff-only, distinto da soma de pledges e de `voteGoals`) · depende de R2 · bloqueia B9 · appetite ~1–1,5 dia · [plano](plans/estimativa-votos-praca.md)
- **B5 F2–F3** cache CLI compartilhado + factory mun/TI (scripts continuam) · [plano](plans/escala-dry-pos-b2.md)
- **B6** `BahiaMap` setStyle incremental ao trocar métrica/ano · gatilho: troca frequente no mapa de Praças · [plano](plans/escala-dry-pos-b3.md)
- **B7** Mapa das Praças filtrado pela mesma URL da lista (`buildPlazaListWhere`) · depende de R2 (entregue) · suave: A9 (métrica 2026) · paralelizável agora · [plano](plans/mapa-pracas-filtrado.md)
- **B7** Mapa das Praças filtrado pela mesma URL da lista (`buildPlazaListWhere`) · depende de R2 (entregue) · A9 ✓ (métrica 2026) · paralelizável agora · [plano](plans/mapa-pracas-filtrado.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; F2 dissolve IBGE/malha → TopoJSON no mapa · Janela 3 · cortável (F2) · [plano](plans/poligonos-pracas-zona.md)
- **B9** Edição rápida na lista de Praças (Assessores, Tendência, **Votos estimados = `expectedVotes`**) · **depende de A9** · appetite ~1–1,5 dia · [plano](plans/edicao-rapida-lista-pracas.md)
- **B10** Hover/tap no Mapa das Praças: destaque + votos; desktop click navega; mobile 2º tap na selecionada navega · depende de R2 · suave: A9/B6/B7/B8 · paralelizável agora · appetite ~0,5–1 dia · [plano](plans/hover-mapa-pracas.md)
- **B11** Escala de cor por **% dos votos válidos** da Praça (0–100%, não pelo total da maior Praça) · depende de R2 (entregue) · suave: A9 (numerador 2026) · paralelizável agora · appetite ~0,5–1 dia · [plano](plans/escala-percentual-mapa-pracas.md)
- **B9** Edição rápida na lista de Praças (Assessores, Tendência, **Votos estimados = `expectedVotes`**) · depende de A9 ✓ · appetite ~1–1,5 dia · [plano](plans/edicao-rapida-lista-pracas.md)
- **B10** Hover/tap no Mapa das Praças: destaque + votos; desktop click navega; mobile 2º tap na selecionada navega · depende de R2 · suave: A9 ✓/B6/B7/B8 · paralelizável agora · appetite ~0,5–1 dia · [plano](plans/hover-mapa-pracas.md)
- **B11** Escala de cor por **% dos votos válidos** da Praça (0–100%, não pelo total da maior Praça) · depende de R2 (entregue) · suave: A9 ✓ (numerador 2026) · paralelizável agora · appetite ~0,5–1 dia · [plano](plans/escala-percentual-mapa-pracas.md)
- **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais
- **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md)
- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · gatilho: antes de 16/08 · (opcional: mover filtros acima do mapa — ver B7)
- **Fill-ins:** filtros auto na lista de Praças (debounce 1s no `q`, selects já imediatos; remove Buscar) · [plano](plans/filtros-auto-pracas.md) · O0+ ([plano](plans/escala-dry-pos-onda0.md)) · RS+ auth read leve + shells de senha ([plano](plans/escala-dry-pos-reset-senha-perfil.md)) · higiene PascalCase
- **Fill-ins:** filtros auto na lista de Praças (debounce 1s no `q`, selects já imediatos; remove Buscar) · [plano](plans/filtros-auto-pracas.md) · **A9+** loader compartilhado lista/mapa ([plano](plans/escala-dry-pos-a9.md)) · O0+ ([plano](plans/escala-dry-pos-onda0.md)) · RS+ auth read leve + shells de senha ([plano](plans/escala-dry-pos-reset-senha-perfil.md)) · higiene PascalCase
## Bloqueadores atuais

View file

@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache'
import {
assignPlazaAdvisors,
setPlazaExpectedVotes,
setPlazaPoliticalTrend,
updatePlazaStrategy,
} from '@/app/(campaign)/campanha/actions/plaza'
@ -57,6 +58,32 @@ export const updatePlazaStrategyFormAction = async (
}
}
export const setPlazaExpectedVotesFormAction = async (
_state: CampaignFormActionState,
formData: FormData,
): Promise<CampaignFormActionState> => {
try {
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
const expectedVotes =
optionalIntegerFormValue(formData, 'expectedVotes', {
minimum: 0,
maximum: 1_000_000,
}) ?? null
await setPlazaExpectedVotes({ plaza, expectedVotes })
revalidatePath('/campanha/pracas', 'page')
revalidatePath('/campanha/pracas/[slug]', 'page')
return { status: 'success', message: 'Votos estimados atualizados.' }
} catch (error) {
return mapCampaignFormActionError({
error,
safeMessages: ['Somente a coordenação e a assessoria podem editar a Praça.'],
genericMessage:
'Não foi possível salvar os votos estimados. Verifique seu acesso e tente novamente.',
})
}
}
export const setPlazaPoliticalTrendFormAction = async (
_state: CampaignFormActionState,
formData: FormData,

View file

@ -18,6 +18,7 @@ import {
import { getEligibleAdvisorOptions } from '@/utilities/plazaViewModels'
import {
assignPlazaAdvisorsFormAction,
setPlazaExpectedVotesFormAction,
setPlazaPoliticalTrendFormAction,
updatePlazaStrategyFormAction,
} from './formActions'
@ -58,7 +59,7 @@ export default async function PlazaEditPage({ params }: PlazaEditPageProps) {
<h1 className="text-2xl font-semibold tracking-tight">Editar {view.name}</h1>
<p className="text-muted-foreground">
A geografia da Praça é pré-definida e não pode ser alterada. Aqui você edita metas,
inteligência e tendência política.
votos estimados, inteligência e tendência política.
</p>
</header>
@ -75,6 +76,7 @@ export default async function PlazaEditPage({ params }: PlazaEditPageProps) {
plazaID={view.id}
strategy={view.strategy}
strategyFormAction={updatePlazaStrategyFormAction}
expectedVotesFormAction={setPlazaExpectedVotesFormAction}
trendFormAction={setPlazaPoliticalTrendFormAction}
/>
</CampaignPageShell>

View file

@ -36,7 +36,7 @@ import {
import { formatPlazaGeographyLabel, plazaKindLabels } from '@/utilities/plazaUi'
import { loadPlazaUpdatesFeed, parsePlazaUpdateFeedParams } from '@/utilities/plazaUpdatePageData'
import { loadAdvisorSummaries } from '@/utilities/plazaViewModels'
import { loadLeaderPledges, loadPlazaPledges } from '@/utilities/votePledgeData'
import { loadLeaderPledges, loadPlazaPledges, sumStaffPledgeEffectiveTotal } from '@/utilities/votePledgeData'
import { declareVotesFormAction, estimateVotesFormAction } from './pledgeFormActions'
import { createPlazaUpdateFormAction } from './updateFormActions'
@ -155,9 +155,15 @@ const OverviewTab = async ({
}) => {
if (isStaffView && view.strategy) {
const pledges = await loadPlazaPledges(payload, user, view.id)
const leadershipVoteTotal = sumStaffPledgeEffectiveTotal(pledges)
return (
<div className="flex flex-col gap-6">
<PlazaStrategyCard strategy={view.strategy} plazaSlug={view.slug} canEdit />
<PlazaStrategyCard
strategy={view.strategy}
plazaSlug={view.slug}
canEdit
leadershipVoteTotal={leadershipVoteTotal}
/>
<PlazaPledgesPanel pledges={pledges} estimateFormAction={estimateVotesFormAction} />
</div>
)

View file

@ -4,9 +4,11 @@ import type { Payload } from 'payload'
import {
plazaAdvisorsAssignmentSchema,
plazaExpectedVotesSchema,
plazaPoliticalTrendSchema,
plazaStrategyUpdateSchema,
type PlazaAdvisorsAssignmentInput,
type PlazaExpectedVotesInput,
type PlazaPoliticalTrendInput,
type PlazaStrategyUpdateInput,
} from '@/lib/schemas/plaza'
@ -84,6 +86,30 @@ export const setPlazaPoliticalTrend = async (input: PlazaPoliticalTrendInput) =>
return setPlazaPoliticalTrendRecord(payload, actor, input)
}
/** Staff-only total expected votes for the plaza (distinct from pledge aggregates). */
export const setPlazaExpectedVotesRecord = async (
payload: Payload,
actor: CampaignUser,
input: PlazaExpectedVotesInput,
) => {
const { plaza, expectedVotes } = plazaExpectedVotesSchema.parse(input)
const currentActor = await getFreshStaffActor(payload, actor)
return payload.update({
collection: 'plaza',
id: plaza,
data: { expectedVotes },
depth: 0,
user: currentActor,
overrideAccess: false,
})
}
export const setPlazaExpectedVotes = async (input: PlazaExpectedVotesInput) => {
const { payload, actor } = await getCampaignActionContext()
return setPlazaExpectedVotesRecord(payload, actor, input)
}
/** Advisor assignment is coordinator-only; the collection hook validates eligibility. */
export const assignPlazaAdvisorsRecord = async (
payload: Payload,

View file

@ -324,6 +324,21 @@ export const Plaza: CollectionConfig = {
},
],
},
{
name: 'expectedVotes',
type: 'number',
label: 'Votos estimados',
min: 0,
access: {
read: canReadCampaignStaffField,
create: canManageCampaignStaffField,
update: canManageCampaignStaffField,
},
admin: {
description:
'Total esperado da Praça — distinto das metas de cenário e da soma das lideranças.',
},
},
{
name: 'politicalTrend',
type: 'group',

View file

@ -84,7 +84,7 @@ export const CampaignDashboard = ({
metrics={[
{
label: 'Votos estimados',
value: view.pledgeCount ? voteFormatter.format(view.effectiveVotesTotal) : '—',
value: view.staffVoteTotal > 0 ? voteFormatter.format(view.staffVoteTotal) : '—',
emphasize: true,
},
{

View file

@ -4,6 +4,7 @@ import Link from 'next/link'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { StaffPlazaVotesDisplay } from '@/components/campaign/StaffPlazaVotesDisplay'
import {
Table,
TableBody,
@ -19,7 +20,6 @@ import {
} from '@/utilities/plazaUi'
import type { PlazaAdvisorSummary, PlazaListViewModel } from '@/utilities/plazaViewModels'
const voteFormatter = new Intl.NumberFormat('pt-BR')
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
const getInitials = (name: string): string =>
@ -82,10 +82,12 @@ export const PlazaList = ({ plazas, advisorNamesById, isStaffView }: PlazaListPr
<dl className="grid grid-cols-2 gap-2 text-sm">
<div>
<dt className="text-muted-foreground">Votos estimados</dt>
<dd className="font-medium tabular-nums">
{plaza.pledges.pledgeCount
? voteFormatter.format(plaza.pledges.effectiveTotal)
: '—'}
<dd>
<StaffPlazaVotesDisplay
expectedVotes={plaza.expectedVotes}
leadershipEffectiveTotal={plaza.pledges.effectiveTotal}
valueClassName="font-medium tabular-nums"
/>
</dd>
</div>
<div>
@ -151,19 +153,11 @@ export const PlazaList = ({ plazas, advisorNamesById, isStaffView }: PlazaListPr
<AdvisorAvatars names={names} />
</TableCell>
<TableCell>
<span className="font-medium tabular-nums">
{plaza.pledges.pledgeCount
? voteFormatter.format(plaza.pledges.effectiveTotal)
: '—'}
</span>
{plaza.pledges.missingEstimateCount > 0 ? (
<Badge variant="estimate-pending" className="mt-1 block w-fit">
{plaza.pledges.missingEstimateCount}{' '}
{plaza.pledges.missingEstimateCount === 1
? 'declaração sem estimativa'
: 'declarações sem estimativa'}
</Badge>
) : null}
<StaffPlazaVotesDisplay
expectedVotes={plaza.expectedVotes}
leadershipEffectiveTotal={plaza.pledges.effectiveTotal}
valueClassName="font-medium tabular-nums"
/>
</TableCell>
<TableCell>
{plaza.lastUpdateAt

View file

@ -13,7 +13,7 @@ export const PlazaListOverview = ({ view }: { view: PlazaListOverviewData }) =>
metrics={[
{
label: 'Votos estimados no conjunto',
value: view.pledgeCount ? voteFormatter.format(view.effectiveVotesTotal) : '—',
value: view.staffVoteTotal > 0 ? voteFormatter.format(view.staffVoteTotal) : '—',
emphasize: true,
},
{

View file

@ -3,6 +3,7 @@ import Link from 'next/link'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { StaffPlazaVotesDisplay } from '@/components/campaign/StaffPlazaVotesDisplay'
import { plazaPriorityLabels, politicalTrendLabels } from '@/utilities/plazaUi'
import type { PlazaDetailViewModel } from '@/utilities/plazaViewModels'
@ -19,10 +20,12 @@ export const PlazaStrategyCard = ({
strategy,
plazaSlug,
canEdit,
leadershipVoteTotal,
}: {
strategy: NonNullable<PlazaDetailViewModel['strategy']>
plazaSlug: string
canEdit: boolean
leadershipVoteTotal: number
}) => {
const goals = strategy.voteGoals
const trend = strategy.politicalTrend
@ -62,7 +65,16 @@ export const PlazaStrategyCard = ({
) : null}
</div>
<dl className="grid gap-3 sm:grid-cols-3">
<dl className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg bg-muted/40 px-3 py-2 sm:col-span-2">
<dt className="text-xs font-medium text-muted-foreground">Votos estimados</dt>
<dd>
<StaffPlazaVotesDisplay
expectedVotes={strategy.expectedVotes}
leadershipEffectiveTotal={leadershipVoteTotal}
/>
</dd>
</div>
{(
[
['Meta Bom', goals.good],

View file

@ -23,6 +23,7 @@ type PlazaStrategyFormProps = {
plazaID: number
strategy: NonNullable<PlazaDetailViewModel['strategy']>
strategyFormAction: FormAction
expectedVotesFormAction: FormAction
trendFormAction: FormAction
}
@ -86,9 +87,14 @@ export const PlazaStrategyForm = ({
plazaID,
strategy,
strategyFormAction,
expectedVotesFormAction,
trendFormAction,
}: PlazaStrategyFormProps) => {
const [strategyState, submitStrategy, strategyPending] = useActionState(strategyFormAction, {})
const [expectedVotesState, submitExpectedVotes, expectedVotesPending] = useActionState(
expectedVotesFormAction,
{},
)
const [trendState, submitTrend, trendPending] = useActionState(trendFormAction, {})
return (
@ -171,6 +177,49 @@ export const PlazaStrategyForm = ({
</Button>
</form>
<form
action={submitExpectedVotes}
aria-labelledby="plaza-expected-votes-title"
className="flex flex-col gap-4 rounded-xl border p-4"
>
<div className="flex flex-col gap-1">
<h2 id="plaza-expected-votes-title" className="text-base font-medium">
Votos estimados
</h2>
<p className="text-sm text-muted-foreground">
Total esperado da Praça — distinto das metas de cenário e da soma das lideranças. Deixe
em branco para remover.
</p>
</div>
<input type="hidden" name="plazaId" value={plazaID} />
<Field>
<FieldLabel htmlFor="plaza-expected-votes">Total da Praça</FieldLabel>
<Input
id="plaza-expected-votes"
name="expectedVotes"
type="number"
min={0}
inputMode="numeric"
defaultValue={strategy.expectedVotes ?? undefined}
className="min-h-11 sm:max-w-xs"
/>
</Field>
{expectedVotesState.message && expectedVotesState.status !== 'success' ? (
<Alert variant="destructive">
<AlertDescription>{expectedVotesState.message}</AlertDescription>
</Alert>
) : null}
{expectedVotesState.status === 'success' ? (
<Alert>
<AlertDescription>{expectedVotesState.message}</AlertDescription>
</Alert>
) : null}
<Button type="submit" disabled={expectedVotesPending} className="min-h-11 self-start">
{expectedVotesPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Salvar votos estimados
</Button>
</form>
<form action={submitTrend} className="flex flex-col gap-4 rounded-xl border p-4">
<div className="flex flex-col gap-1">
<h2 className="text-base font-medium">Tendência política</h2>

View file

@ -0,0 +1,24 @@
const voteFormatter = new Intl.NumberFormat('pt-BR')
export const StaffPlazaVotesDisplay = ({
expectedVotes,
leadershipEffectiveTotal,
valueClassName = 'text-lg font-medium tabular-nums',
sublineClassName = 'text-xs text-muted-foreground',
}: {
expectedVotes: number | null
leadershipEffectiveTotal: number
valueClassName?: string
sublineClassName?: string
}) => (
<>
<span className={valueClassName}>
{expectedVotes == null ? '—' : voteFormatter.format(expectedVotes)}
</span>
{leadershipEffectiveTotal > 0 ? (
<span className={`mt-1 block ${sublineClassName}`}>
Nas lideranças: {voteFormatter.format(leadershipEffectiveTotal)}
</span>
) : null}
</>
)

View file

@ -37,6 +37,12 @@ export const plazaAdvisorsAssignmentSchema = z.object({
.transform((ids) => [...new Set(ids)]),
})
export const plazaExpectedVotesSchema = z.object({
plaza: positiveRelationshipId,
expectedVotes: z.number().int().min(0).max(1_000_000).nullable(),
})
export type PlazaStrategyUpdateInput = z.input<typeof plazaStrategyUpdateSchema>
export type PlazaPoliticalTrendInput = z.input<typeof plazaPoliticalTrendSchema>
export type PlazaAdvisorsAssignmentInput = z.input<typeof plazaAdvisorsAssignmentSchema>
export type PlazaExpectedVotesInput = z.input<typeof plazaExpectedVotesSchema>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "plaza" ADD COLUMN "expected_votes" numeric;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "plaza" DROP COLUMN "expected_votes";`)
}

View file

@ -16,6 +16,7 @@ import * as migration_20260719_054706_add_privacy_policy_global from './20260719
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';
export const migrations = [
@ -109,9 +110,14 @@ export const migrations = [
down: migration_20260721_020109_remodel_plazas.down,
name: '20260721_020109_remodel_plazas',
},
{
up: migration_20260721_133444_add_plaza_expected_votes.up,
down: migration_20260721_133444_add_plaza_expected_votes.down,
name: '20260721_133444_add_plaza_expected_votes',
},
{
up: migration_20260721_133531_add_petition_facebook_pixel_id.up,
down: migration_20260721_133531_add_petition_facebook_pixel_id.down,
name: '20260721_133531_add_petition_facebook_pixel_id'
name: '20260721_133531_add_petition_facebook_pixel_id',
},
];

View file

@ -405,6 +405,10 @@ export interface Plaza {
regular?: number | null;
minimum?: number | null;
};
/**
* Total esperado da Praça — distinto das metas de cenário e da soma das lideranças.
*/
expectedVotes?: number | null;
/**
* Leitura de conjuntura registrada pela coordenação (alianças, prefeitos, disputas locais) — não é série numérica.
*/
@ -1234,6 +1238,7 @@ export interface PlazaSelect<T extends boolean = true> {
regular?: T;
minimum?: T;
};
expectedVotes?: T;
politicalTrend?:
| T
| {

View file

@ -6,6 +6,7 @@ import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
import {
aggregatePledgesByPlaza,
loadLeaderPledges,
rollupPlazaStaffVotes,
type LeaderPledgeRow,
} from '@/utilities/votePledgeData'
@ -15,7 +16,7 @@ export type StaffDashboardView = {
plazaCount: number
withAdvisorCount: number
highPriorityCount: number
effectiveVotesTotal: number
staffVoteTotal: number
declaredVotesTotal: number
pledgeCount: number
missingEstimateCount: number
@ -76,7 +77,7 @@ const buildStaffDashboard = async (
depth: 0,
limit: 0,
pagination: false,
select: { name: true, slug: true, advisors: true, priority: true },
select: { name: true, slug: true, advisors: true, priority: true, expectedVotes: true },
where: {},
user,
overrideAccess: false,
@ -85,16 +86,12 @@ const buildStaffDashboard = async (
const plazaById = new Map(plazas.docs.map((plaza) => [plaza.id, plaza]))
const pledgeAggregates = await aggregatePledgesByPlaza(payload, plazaIDs)
let effectiveVotesTotal = 0
let declaredVotesTotal = 0
let pledgeCount = 0
let missingEstimateCount = 0
for (const aggregate of pledgeAggregates.values()) {
effectiveVotesTotal += aggregate.effectiveTotal
declaredVotesTotal += aggregate.declaredTotal
pledgeCount += aggregate.pledgeCount
missingEstimateCount += aggregate.missingEstimateCount
}
const {
staffVoteTotal,
declaredVotesTotal,
pledgeCount,
missingEstimateCount,
} = rollupPlazaStaffVotes(plazas.docs, pledgeAggregates)
const missingEstimatePledges = plazaIDs.length
? await payload.find({
@ -149,7 +146,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,
effectiveVotesTotal,
staffVoteTotal,
declaredVotesTotal,
pledgeCount,
missingEstimateCount,

View file

@ -11,7 +11,11 @@ import {
loadCandidateVotesByCityZone,
sumVotesForGeography,
} from '@/utilities/plazaElectoralBaseline'
import { aggregatePledgesByPlaza } from '@/utilities/votePledgeData'
import {
aggregatePledgesByPlaza,
emptyPlazaPledgeAggregate,
resolvePlazaStaffVoteTotal,
} from '@/utilities/votePledgeData'
export const PLAZA_MAP_YEARS = [...HISTORICAL_SERIES_YEARS, 2026] as const
export type PlazaMapYear = (typeof PLAZA_MAP_YEARS)[number]
@ -37,7 +41,7 @@ export type PlazaMapComparison = {
}
export type PlazaMapBundle = {
/** year (as string) → codarea → value. 2026 = estimated ?? declared pledges. */
/** year (as string) → codarea → value. 2026 = expectedVotes ?? pledge effective total. */
valuesByYear: Record<string, Record<string, number>>
/** Zone plazas in scope (Salvador/Camaçari) with per-year values. */
zoneBreakdown: PlazaZoneBreakdownRow[]
@ -51,6 +55,7 @@ type ScopedPlaza = {
name: string
kind: 'municipio' | 'zona'
ibgeCode: string
expectedVotes: number | null
geography: PlazaElectionGeography
}
@ -60,7 +65,7 @@ const loadScopedPlazas = async (payload: Payload, user: CampaignUser): Promise<S
depth: 0,
limit: 0,
pagination: false,
select: { slug: true, name: true, kind: true, ibgeCode: true },
select: { slug: true, name: true, kind: true, ibgeCode: true, expectedVotes: true },
where: {},
user,
overrideAccess: false,
@ -76,6 +81,7 @@ const loadScopedPlazas = async (payload: Payload, user: CampaignUser): Promise<S
name: plaza.name,
kind: plaza.kind,
ibgeCode: plaza.ibgeCode,
expectedVotes: plaza.expectedVotes ?? null,
geography: plazaElectionGeography(entry),
},
]
@ -128,8 +134,8 @@ export const loadPlazaMapBundle = async (
)
const pledgeValues: Record<string, number> = {}
for (const plaza of plazas) {
const aggregate = pledgeAggregates.get(plaza.id)
const votes = aggregate?.effectiveTotal ?? 0
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) ?? {}

View file

@ -12,7 +12,10 @@ import {
type PlazaListViewModel,
} from '@/utilities/plazaViewModels'
import { relationshipId } from '@/utilities/relationship'
import { aggregatePledgesByPlaza } from '@/utilities/votePledgeData'
import {
aggregatePledgesByPlaza,
rollupPlazaStaffVotes,
} from '@/utilities/votePledgeData'
type PlazaListSearchParams = Record<string, string | string[] | undefined>
@ -74,7 +77,7 @@ export const loadPlazaListPageData = async (
export type PlazaListOverviewData = {
plazaCount: number
effectiveVotesTotal: number
staffVoteTotal: number
declaredVotesTotal: number
pledgeCount: number
missingEstimateCount: number
@ -97,7 +100,7 @@ export const loadPlazaListOverviewData = async (
limit: 0,
pagination: false,
where: buildPlazaListWhere(state),
select: { advisors: true, priority: true },
select: { advisors: true, priority: true, expectedVotes: true },
user,
overrideAccess: false,
})
@ -105,21 +108,16 @@ export const loadPlazaListOverviewData = async (
const plazaIDs = result.docs.map((plaza) => plaza.id)
const pledgeAggregates = await aggregatePledgesByPlaza(payload, plazaIDs)
let effectiveVotesTotal = 0
let declaredVotesTotal = 0
let pledgeCount = 0
let missingEstimateCount = 0
for (const aggregate of pledgeAggregates.values()) {
effectiveVotesTotal += aggregate.effectiveTotal
declaredVotesTotal += aggregate.declaredTotal
pledgeCount += aggregate.pledgeCount
missingEstimateCount += aggregate.missingEstimateCount
}
const {
staffVoteTotal,
declaredVotesTotal,
pledgeCount,
missingEstimateCount,
} = rollupPlazaStaffVotes(result.docs, pledgeAggregates)
return {
plazaCount: result.docs.length,
effectiveVotesTotal,
staffVoteTotal,
declaredVotesTotal,
pledgeCount,
missingEstimateCount,

View file

@ -18,6 +18,7 @@ export const plazaListSelect = {
advisors: true,
priority: true,
lastUpdateAt: true,
expectedVotes: true,
} as const
export type PlazaListViewModel = {
@ -32,6 +33,7 @@ export type PlazaListViewModel = {
advisorIDs: number[]
priority: 'alta' | 'normal'
lastUpdateAt: string | null
expectedVotes: number | null
pledges: PlazaPledgeAggregate
}
@ -50,6 +52,7 @@ 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,
pledges: pledges ?? { ...emptyPlazaPledgeAggregate },
})
@ -82,6 +85,7 @@ export type PlazaDetailViewModel = {
/** Staff-only block — null for the leader view model. */
strategy: {
priority: 'alta' | 'normal'
expectedVotes: number | null
voteGoals: VoteGoalsViewModel
politicalTrend: PlazaPoliticalTrendViewModel
strengths: string[]
@ -114,6 +118,7 @@ export const toPlazaDetailViewModel = (
? null
: {
priority: plaza.priority === 'alta' ? 'alta' : 'normal',
expectedVotes: plaza.expectedVotes ?? null,
voteGoals: toVoteGoalsViewModel(plaza.voteGoals),
politicalTrend: {
status: plaza.politicalTrend?.status ?? null,

View file

@ -23,6 +23,42 @@ export const emptyPlazaPledgeAggregate: PlazaPledgeAggregate = {
missingEstimateCount: 0,
}
/** Staff-facing plaza total: manual expected votes override pledge aggregate when set. */
export const resolvePlazaStaffVoteTotal = (
expectedVotes: number | null | undefined,
pledgeEffectiveTotal: number,
): number => expectedVotes ?? pledgeEffectiveTotal
export const sumStaffPledgeEffectiveTotal = (
rows: ReadonlyArray<{ declaredVotes: number; estimatedVotes: number | null }>,
): number =>
rows.reduce((sum, row) => sum + (row.estimatedVotes ?? row.declaredVotes), 0)
export type PlazaStaffVoteRollup = {
staffVoteTotal: number
declaredVotesTotal: number
pledgeCount: number
missingEstimateCount: number
}
export const rollupPlazaStaffVotes = (
plazas: ReadonlyArray<{ id: number; expectedVotes?: number | null }>,
pledgeAggregates: Map<number, PlazaPledgeAggregate>,
): PlazaStaffVoteRollup => {
let staffVoteTotal = 0
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)
declaredVotesTotal += aggregate.declaredTotal
pledgeCount += aggregate.pledgeCount
missingEstimateCount += aggregate.missingEstimateCount
}
return { staffVoteTotal, declaredVotesTotal, pledgeCount, missingEstimateCount }
}
/**
* Staff-only aggregate over an already access-checked plaza id set.
* Intentional admin bypass: callers pass plaza ids the actor may read.

View file

@ -6,6 +6,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
import {
assignPlazaAdvisorsRecord,
setPlazaExpectedVotesRecord,
setPlazaPoliticalTrendRecord,
updatePlazaStrategyRecord,
} from '@/app/(campaign)/campanha/actions/plaza'
@ -150,6 +151,10 @@ describe('plaza catalog seed and per-role access', () => {
status: 'desfavoravel',
note: 'Prefeito rompeu com a chapa.',
})
await setPlazaExpectedVotesRecord(payload, coordinator, {
plaza: plaza.id,
expectedVotes: 1200,
})
fixtures.touchPlaza(plaza.id)
const leaderRead = await payload.findByID({
@ -163,6 +168,7 @@ describe('plaza catalog seed and per-role access', () => {
expect(doc.name).toBe(plaza.name)
expect(doc.priority ?? null).toBeNull()
expect(doc.voteGoals ?? null).toBeNull()
expect(doc.expectedVotes ?? null).toBeNull()
expect(doc.politicalTrend ?? null).toBeNull()
// Read-denied arrays come back empty — the staff content must not leak.
expect(doc.strengths ?? []).toEqual([])
@ -176,6 +182,7 @@ describe('plaza catalog seed and per-role access', () => {
overrideAccess: false,
})
expect(staffRead.priority).toBe('alta')
expect(staffRead.expectedVotes).toBe(1200)
expect(staffRead.politicalTrend?.status).toBe('desfavoravel')
expect(staffRead.politicalTrend?.recordedBy).toBeTruthy()
expect(staffRead.politicalTrend?.recordedAt).toBeTruthy()
@ -236,4 +243,41 @@ describe('plaza catalog seed and per-role access', () => {
)
expect(scope).toEqual([plaza.id])
})
it('lets staff set expectedVotes with role and plaza scope enforced', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getPlaza()
const outside = await fixtures.getPlaza()
await fixtures.assignPlazaAdvisors(administered.id, [advisor.id])
const updated = await setPlazaExpectedVotesRecord(payload, coordinator, {
plaza: administered.id,
expectedVotes: 2500,
})
expect(updated.expectedVotes).toBe(2500)
fixtures.touchPlaza(administered.id)
const advisorUpdated = await setPlazaExpectedVotesRecord(payload, advisor, {
plaza: administered.id,
expectedVotes: 3000,
})
expect(advisorUpdated.expectedVotes).toBe(3000)
fixtures.touchPlaza(administered.id)
await expect(
setPlazaExpectedVotesRecord(payload, advisor, {
plaza: outside.id,
expectedVotes: 100,
}),
).rejects.toThrow()
const cleared = await setPlazaExpectedVotesRecord(payload, coordinator, {
plaza: administered.id,
expectedVotes: null,
})
expect(cleared.expectedVotes).toBeNull()
fixtures.touchPlaza(administered.id)
})
})

View file

@ -119,6 +119,7 @@ describe('campaign visual foundation', () => {
advisorIDs: [advisor.id],
priority: 'alta',
lastUpdateAt: null,
expectedVotes: 1500,
pledges: {
declaredTotal: 1200,
effectiveTotal: 1200,
@ -138,6 +139,7 @@ describe('campaign visual foundation', () => {
advisorIDs: [],
priority: 'normal',
lastUpdateAt: null,
expectedVotes: null,
pledges: { ...emptyPlazaPledgeAggregate },
},
]
@ -153,8 +155,8 @@ describe('campaign visual foundation', () => {
expect(html).toContain('Seabra')
expect(html).toContain('Chapada Diamantina')
expect(html).toContain('href="/campanha/pracas/seabra"')
expect(html).toContain('1.200')
expect(html).toContain('declaração sem estimativa')
expect(html).toContain('1.500')
expect(html).toContain('Nas lideranças: 1.200')
expect(html).toContain('Prioritária')
expect(html).toContain('Coberta')
expect(html).toContain('Sem assessor')
@ -176,6 +178,7 @@ describe('campaign visual foundation', () => {
advisorIDs: [],
priority: 'alta',
lastUpdateAt: null,
expectedVotes: null,
pledges: { ...emptyPlazaPledgeAggregate },
},
],

View file

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
emptyPlazaPledgeAggregate,
resolvePlazaStaffVoteTotal,
rollupPlazaStaffVotes,
} from '@/utilities/votePledgeData'
describe('resolvePlazaStaffVoteTotal', () => {
it('falls back to pledge effective total when expectedVotes is null', () => {
expect(resolvePlazaStaffVoteTotal(null, 420)).toBe(420)
})
it('uses expectedVotes when set, ignoring pledge aggregate', () => {
expect(resolvePlazaStaffVoteTotal(1500, 420)).toBe(1500)
})
it('returns zero when both expectedVotes and pledges are empty', () => {
expect(resolvePlazaStaffVoteTotal(null, 0)).toBe(0)
})
})
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 }],
])
expect(
rollupPlazaStaffVotes(
[
{ id: 1, expectedVotes: null },
{ id: 2, expectedVotes: 200 },
],
aggregates,
),
).toEqual({
staffVoteTotal: 300,
declaredVotesTotal: 130,
pledgeCount: 3,
missingEstimateCount: 1,
})
})
})