diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index cdce696d..44ffe42c 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -120,7 +120,7 @@ alwaysApply: false - **Bloqueador obrigatório de produção (PII real):** antes de lideranças/apoiadores reais ou import em massa, a assessoria jurídica eleitoral deve aprovar os textos definitivos (substituindo os provisórios do [Onda 0 MVP](../../../docs/plans/onda-0.md)). Os textos provisórios já sobem automaticamente via migration; o hold é de **titulares reais**, não de engenharia. - Em 2026-07-17 o roadmap foi repriorizado orientado ao calendário eleitoral (Res. TSE 23.760/2026: propaganda a partir de 16/08, 1º turno 04/10), com ordem de execução, trilhas paralelas (A dados eleitorais/território, B superfícies de coordenação, C operação de campo, D plataforma) e grafo de dependências. Decisões de sequenciamento: lançar o MVP antes da migração multi-município; multi-município antes de zonas-por-municipio/eventos/demandas; baseline TSE independente de zonas-por-municipio (dependência rebaixada a suave); WhatsApp Business API movido a fora de escopo (vedação Meta + Res. 23.610 art. 33). - Backlog consolidado do produto (admin RBAC, mapa, eventos, demandas, apoiadores, listas globais, site, white-label): [`docs/roadmap.md`](../../../docs/roadmap.md). -- **B7 registrado (2026-07-21):** mapa em `/campanha/pracas` deve usar o mesmo `buildPlazaListWhere` da lista/overview (hoje só escopo de access). Plano: [`docs/plans/mapa-pracas-filtrado.md`](../../../docs/plans/mapa-pracas-filtrado.md). Quick win Janela 1; cortável se o prazo apertar. +- **B7 entregue (2026-07-21):** mapa em `/campanha/pracas` usa `buildPlazaListWhere` (lista/overview/mapa alinhados). Plano: [`docs/plans/mapa-pracas-filtrado.md`](../../../docs/plans/mapa-pracas-filtrado.md). Débito perf triplicado → **A9+**. - **A9 registrado (2026-07-21):** total esperado da Praça (`expectedVotes` staff-only), distinto da soma de `votePledge` e de `voteGoals`; mapa/lista usam `expectedVotes ?? effectiveTotal`. Plano: [`docs/plans/estimativa-votos-praca.md`](../../../docs/plans/estimativa-votos-praca.md). Bloqueia B9; não cortável. - **B9 atualizado (2026-07-21):** edição rápida na lista (Assessores / Tendência / **Votos estimados = `expectedVotes`** direto). Depende de A9. Plano: [`docs/plans/edicao-rapida-lista-pracas.md`](../../../docs/plans/edicao-rapida-lista-pracas.md). - **B8 registrado (2026-07-21):** polígonos das Praças-zona Salvador/Camaçari. Pesquisa: SSA tem lista oficial TRE-BA zona↔bairro (RA 02/2017); CMS **não** — só locais de votação por ZE 170/171 + curadoria; TSE/TRE não publicam polígonos de ZE. Plano: F1 catálogo+UI na Praça; F2 dissolve malha de bairros (IBGE/GeoSalvador) → TopoJSON. Janela 3; F2 cortável. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md). diff --git a/docs/plans/escala-dry-pos-a9.md b/docs/plans/escala-dry-pos-a9.md index 1b018776..71e1e949 100644 --- a/docs/plans/escala-dry-pos-a9.md +++ b/docs/plans/escala-dry-pos-a9.md @@ -1,7 +1,7 @@ # 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`) +Status: registrado no roadmap (Fase 1 pendente; **B7 entregue 2026-07-21** — prioridade sobe) +Atualizado em: 2026-07-21 (`capture-review-debts` pós-B7 + `/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) @@ -15,11 +15,15 @@ Uma passagem `/simplify` pós-entrega já limpou o que cabia em cleanup: form `o 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. +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 do conjunto filtrado do mapa — desde **B7** o escopo segue `buildPlazaListWhere`, não mais todas as Praças acessíveis). Cada loader ainda invoca `aggregatePledgesByPlaza` de forma independente. -**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. +2. **Dois `find` de Praças no mesmo filtro (overview + mapa).** Ambos usam `buildPlazaListWhere` com `pagination: false` e `select` distinto — duplicação de round-trip por request quando o mapa está visível (staff, conjunto não vazio). -**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**. +**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. **Pós-B7 simplify:** `PlazaListSearchParams` exportado de `plazaUi.ts`; parse único no map loader (`loadScopedPlazas` recebe `PlazaListState`). + +**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-A9):** 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**. + +**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B7):** `Promise.all` nos loads TSE por ano em `plazaMapData` (micro-opt pré-existente; gatilho abaixo); testes int extras em `plazaMapData` (`region`/`leader`/`compare`) — cobertura opcional no próximo toque no loader; split do teste advisor por `kind` (legibilidade). ## Objetivos @@ -33,7 +37,7 @@ O revisor de **performance** marcou como **maior que simplify** o follow-up abai - **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). +- **Cortável** se a lista com mapa permanecer aceitável em campo com dezenas de assessores; **menos cortável desde B7 entregue** (mapa filtrado + sempre visível para staff amplifica o custo do triplo agregado quando o filtro é amplo). - **i18n e naming** (AGENTS.md): identificadores em inglês (`loadPlazaListPageBundle`, `PlazaListPledgeAggregates`); strings visíveis inalteradas em pt-BR. ## Questões em aberto @@ -50,7 +54,7 @@ flowchart TD F1 --> List["PlazaList página"] F1 --> Overview["PlazaListOverview KPIs"] F1 --> Map["PlazaMapBundle 2026"] - B7["B7 mapa filtrado"] -.mesmo bundle.-> F1 + B7["B7 mapa filtrado ✓"] -.mesmo bundle.-> F1 ``` ### Fase 1 — Loader compartilhado da lista de Praças @@ -68,7 +72,7 @@ flowchart TD ## 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. +- **Suave:** B7 [mapa-pracas-filtrado.md](mapa-pracas-filtrado.md) — entregue 2026-07-21; mesmo URL de filtro; F1 deduplica pledges e pode compartilhar parse/`find` onde os escopos coincidem. - Reusa: `aggregatePledgesByPlaza`, `rollupPlazaStaffVotes`, `resolvePlazaStaffVoteTotal`, precedente E6 F1 em [escala-dry-pos-e1.md](escala-dry-pos-e1.md). ## Não escopo @@ -88,6 +92,7 @@ flowchart TD - **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. +- **Loads TSE sequenciais por ano em `loadPlazaMapBundle`.** Revisitar com `Promise.all` só se profiling com `?compare=` mostrar latência dominante (pré-existente; fora do escopo A9+). ## Referências diff --git a/docs/plans/mapa-pracas-filtrado.md b/docs/plans/mapa-pracas-filtrado.md index 7381d854..bfe7466e 100644 --- a/docs/plans/mapa-pracas-filtrado.md +++ b/docs/plans/mapa-pracas-filtrado.md @@ -1,12 +1,14 @@ # Mapa das Praças filtrado pela lista -Status: rascunho +Status: entregue Atualizado em: 2026-07-21 -Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B7) +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B7 — entregue 2026-07-21) Impeccable: B — encaixe em `/campanha/pracas` (`PlazaMapPanel` + loader; sem rota nova) Appetite: ~0,5 dia eng; wire `buildPlazaListWhere` no loader do mapa + teste + empty state Responsável: — +_Revisão 2026-07-21 (pós-implementação + `/simplify`): `loadPlazaMapBundle` aceita `rawSearchParams`; `loadScopedPlazas` usa `buildPlazaListWhere(state)`; tipo `PlazaListSearchParams` exportado de `plazaUi.ts`; testes em `tests/int/plazaMapData.int.spec.ts`. Cleanup simplify: parse único no map loader (`PlazaListState` passado a `loadScopedPlazas`). Débito de perf triplicado → **A9+**._ + ## Design (Impeccable) Âncoras: `PRODUCT.md` / `DESIGN.md` (register product — Field Desk) · tema `data-theme='campaign'` · shells existentes (`CampaignPageShell`, `PlazaFilters`, `PlazaMapPanel`). @@ -22,12 +24,9 @@ Brief compacto: ## Contexto -Em `/campanha/pracas` a lista e o overview já aplicam `buildPlazaListWhere(state)` a partir da URL (`q`, `region`, `kind`, `coverage`, `priority`, `trend`). O mapa, porém, carrega **todas** as Praças acessíveis ao papel: +Em `/campanha/pracas` a lista e o overview já aplicavam `buildPlazaListWhere(state)` a partir da URL (`q`, `region`, `kind`, `coverage`, `priority`, `trend`). O mapa carregava **todas** as Praças acessíveis ao papel (`where: {}` em `loadScopedPlazas`). Pedido de produto (2026-07-21): o Mapa das Praças deve usar o mesmo filtro da lista abaixo. -- `src/app/(campaign)/campanha/(app)/pracas/page.tsx` chama `loadPlazaMapBundle(payload, user, canonicalUrl.state.compare)` **sem** o estado de filtro. -- `loadScopedPlazas` em `src/utilities/plazaMapData.ts` usa `where: {}` (só access via `overrideAccess: false`). - -Isso quebra o contrato mental que a era Núcleos já tinha documentado: o mapa do overview era “escopado pelos mesmos filtros da lista” ([mapa-bahia-geometrias.md](mapa-bahia-geometrias.md)). Na remodelagem Praças (R2) o mapa nasceu com escopo de **access** (assessor vê só as suas), mas não com escopo de **filtro de UI**. Pedido de produto (2026-07-21): o Mapa das Praças deve usar o mesmo filtro da lista abaixo. +**Entregue 2026-07-21:** `loadPlazaMapBundle(payload, user, rawSearchParams)` em `page.tsx`; `loadScopedPlazas` com `buildPlazaListWhere`; conjunto vazio → `null` (painel omitido); `compare` só modo comparativo. ## Objetivos @@ -63,8 +62,8 @@ flowchart LR Componentes: -- **`loadScopedPlazas` / `loadPlazaMapBundle`** (`src/utilities/plazaMapData.ts`): aceitar `PlazaListState` (ou `Where` já montado). Passar `where: buildPlazaListWhere(state)` no `payload.find` (manter `user` + `overrideAccess: false`). Se zero docs → `null`. `compareCandidateNumber` permanece argumento separado (ou lido de `state.compare`). -- **`pracas/page.tsx`**: passar o mesmo `state`/`rawSearchParams` usado pela lista ao loader do mapa (ex.: `loadPlazaMapBundle(payload, user, canonicalUrl.state)`), não só `compare`. +- **`loadScopedPlazas` / `loadPlazaMapBundle`** (`src/utilities/plazaMapData.ts`): `searchParams` → `parsePlazaListParams` → `buildPlazaListWhere(state)` no `payload.find` (`user` + `overrideAccess: false`). `loadScopedPlazas` recebe `PlazaListState` (parse único no bundle). Zero docs → `null`. `compare` de `state.compare`. +- **`pracas/page.tsx`**: `loadPlazaMapBundle(payload, user, rawSearchParams)` — paridade com lista/overview. - **Depth check:** não criar `plazaMapFilters.ts` pass-through; não duplicar clauses de `q`/`region`/… — só reusar `buildPlazaListWhere` de `plazaUi.ts`. - **Teste:** int (ou unit com Payload mock) garantindo que com `region` (ou `kind`) o bundle só contém `ibgeCode`s das Praças filtradas; e que conjunto vazio → `null`. Precedente de access já comentado no loader (assessor). - **Migration:** Sem migration, sem collection, sem server action. @@ -98,8 +97,9 @@ Componentes: - `docs/roadmap.md` (Trilha B, item B7; Remodelagem R2) - `src/app/(campaign)/campanha/(app)/pracas/page.tsx` — composição mapa + filtros + lista -- `src/utilities/plazaMapData.ts` — `loadScopedPlazas` / `loadPlazaMapBundle` (hoje `where: {}`) -- `src/utilities/plazaUi.ts` — `buildPlazaListWhere`, `PlazaListState` +- `src/utilities/plazaMapData.ts` — `loadScopedPlazas` / `loadPlazaMapBundle` +- `src/utilities/plazaUi.ts` — `buildPlazaListWhere`, `PlazaListState`, `PlazaListSearchParams` +- `tests/int/plazaMapData.int.spec.ts` — contrato filtro + access - `src/utilities/plazaPageData.ts` — precedente overview filtrado (`pagination: false`) - `docs/plans/mapa-bahia-geometrias.md` — contrato histórico “mapa escopado pelos mesmos filtros” - `docs/plans/remodelagem-pracas.md` — mapa v1 / access do assessor diff --git a/docs/roadmap.md b/docs/roadmap.md index a0603120..1dcccd23 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-21 (A9 entregue; A9+ loader lista; B9 desbloqueado; Pixel Meta) +Atualizado em: 2026-07-21 (B7 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). @@ -70,22 +70,21 @@ flowchart TD R2 --> D2["D2 Push + sino"] R2 --> A9["A9 Estimativa votos ✓"] A9 --> B9["B9 Edição rápida lista Praças"] - R2 -.-> B7["B7 Mapa filtrado pela lista"] + R2 -.-> B7["B7 Mapa filtrado ✓"] R2 -.-> B8["B8 Polígonos Praças-zona
(SSA/CMS)"] R2 -.-> B10["B10 Hover/tap mapa
(destaque + nav)"] R2 -.-> B11["B11 Escala % válidos
no mapa"] - A9 -.métrica 2026.-> B7 A9 -.métrica 2026.-> B10 A9 -.numerador 2026.-> B11 JUR -.chave push.-> D2 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** entregue — **B9** desbloqueado (seta dura A9 → B9). +Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): **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; ~~**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 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos da Praça~~ entregue; ~~**B7** mapa filtrado pela lista~~ 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); **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. @@ -97,7 +96,7 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): **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** (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; **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) @@ -105,6 +104,7 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): - **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). +- **B7 (2026-07-21)** — **Mapa das Praças filtrado pela lista** (`buildPlazaListWhere` em `loadPlazaMapBundle`; `rawSearchParams` na página; empty → omitir painel; int `plazaMapData.int.spec.ts`) — [plano](plans/mapa-pracas-filtrado.md). ## Supersedidos pela remodelagem (2026-07-20) @@ -127,14 +127,13 @@ Paralelizáveis sem seta dura de entrada (além das tracejadas a partir de R2): - **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) - **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) · 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) +- **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/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) +- **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) - **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 diff --git a/src/app/(campaign)/campanha/(app)/pracas/page.tsx b/src/app/(campaign)/campanha/(app)/pracas/page.tsx index 95ce1f05..501933ba 100644 --- a/src/app/(campaign)/campanha/(app)/pracas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/pracas/page.tsx @@ -53,7 +53,7 @@ export default async function PlazasPage({ searchParams }: PlazasPageProps) { const [listData, overview, mapBundle] = await Promise.all([ loadPlazaListPageData(payload, user, rawSearchParams), loadPlazaListOverviewData(payload, user, rawSearchParams), - loadPlazaMapBundle(payload, user, canonicalUrl.state.compare), + loadPlazaMapBundle(payload, user, rawSearchParams), ]) const candidateOptions = mapBundle ? await loadFederalCandidateOptions(payload, user) : [] const resolvedUrl = resolvePlazaListUrl(rawSearchParams, listData.totalPages) diff --git a/src/utilities/plazaMapData.ts b/src/utilities/plazaMapData.ts index ae8e7c71..4efcbdd4 100644 --- a/src/utilities/plazaMapData.ts +++ b/src/utilities/plazaMapData.ts @@ -11,6 +11,12 @@ import { loadCandidateVotesByCityZone, sumVotesForGeography, } from '@/utilities/plazaElectoralBaseline' +import { + buildPlazaListWhere, + parsePlazaListParams, + type PlazaListSearchParams, + type PlazaListState, +} from '@/utilities/plazaUi' import { aggregatePledgesByPlaza, emptyPlazaPledgeAggregate, @@ -59,14 +65,18 @@ type ScopedPlaza = { geography: PlazaElectionGeography } -const loadScopedPlazas = async (payload: Payload, user: CampaignUser): Promise => { +const loadScopedPlazas = async ( + payload: Payload, + user: CampaignUser, + state: PlazaListState, +): Promise => { const result = await payload.find({ collection: 'plaza', depth: 0, limit: 0, pagination: false, select: { slug: true, name: true, kind: true, ibgeCode: true, expectedVotes: true }, - where: {}, + where: buildPlazaListWhere(state), user, overrideAccess: false, }) @@ -89,20 +99,20 @@ const loadScopedPlazas = async (payload: Payload, user: CampaignUser): Promise => { if (user.role === 'leader') return null - const plazas = await loadScopedPlazas(payload, user) + const state = parsePlazaListParams(searchParams) + const plazas = await loadScopedPlazas(payload, user, state) if (plazas.length === 0) return null const valuesByYear: Record> = {} @@ -155,12 +165,12 @@ export const loadPlazaMapBundle = async ( .sort((left, right) => left.name.localeCompare(right.name, 'pt-BR')) let comparison: PlazaMapComparison | null = null - if (compareCandidateNumber) { + if (state.compare) { const candidates = await payload.find({ collection: 'electionCandidate', where: { and: [ - { candidateNumber: { equals: compareCandidateNumber } }, + { candidateNumber: { equals: state.compare } }, { office: { equals: 'deputado_federal' } }, { turn: { equals: '1' } }, ], @@ -179,7 +189,7 @@ export const loadPlazaMapBundle = async ( for (const year of HISTORICAL_SERIES_YEARS) { const otherVotes = await loadCandidateVotesByCityZone(payload, user, { year, - candidateNumber: compareCandidateNumber, + candidateNumber: state.compare, }) const sollaVotes = sollaVotesByYear.get(year) ?? new Map() const values: Record = {} @@ -192,7 +202,7 @@ export const loadPlazaMapBundle = async ( } comparison = { - candidateNumber: compareCandidateNumber, + candidateNumber: state.compare, candidateName: `${candidate.urnaName}${candidate.party ? ` (${candidate.party})` : ''}`, diffByYear, } diff --git a/src/utilities/plazaPageData.ts b/src/utilities/plazaPageData.ts index 7962df62..2c1f7f78 100644 --- a/src/utilities/plazaPageData.ts +++ b/src/utilities/plazaPageData.ts @@ -3,7 +3,7 @@ import type { Payload } from 'payload' import { getPlazaCatalogEntry } from '@/lib/plazaCatalog' import type { CampaignUser, Plaza } from '@/payload-types' import { isCampaignStaff } from '@/utilities/campaignAccess' -import { buildPlazaListWhere, parsePlazaListParams, plazaPageSize } from '@/utilities/plazaUi' +import { buildPlazaListWhere, parsePlazaListParams, plazaPageSize, type PlazaListSearchParams } from '@/utilities/plazaUi' import { plazaListSelect, toPlazaDetailViewModel, @@ -17,8 +17,6 @@ import { rollupPlazaStaffVotes, } from '@/utilities/votePledgeData' -type PlazaListSearchParams = Record - export class PlazaNotFoundError extends Error { override name = 'PlazaNotFoundError' diff --git a/src/utilities/plazaUi.ts b/src/utilities/plazaUi.ts index eaf5eb91..3d0530b2 100644 --- a/src/utilities/plazaUi.ts +++ b/src/utilities/plazaUi.ts @@ -44,7 +44,7 @@ export type PlazaListState = { compare?: number } -type RawSearchParams = CampaignListRawSearchParams +export type PlazaListSearchParams = CampaignListRawSearchParams export const plazaListParamNames = [ 'q', @@ -63,7 +63,7 @@ const canonicalTerritoryBySearchValue = new Map( bahiaIdentityTerritories.map((territory) => [normalizeSearchPhrase(territory), territory]), ) -export const parsePlazaListParams = (params: RawSearchParams): PlazaListState => { +export const parsePlazaListParams = (params: PlazaListSearchParams): PlazaListState => { const rawPage = strictDecimalInteger(firstValue(params.page)) const q = normalizedText(firstValue(params.q)) const rawRegion = normalizedText(firstValue(params.region)) @@ -153,7 +153,7 @@ export const buildPlazaListHref = (state: PlazaListState, page: number): string buildListHref(state, buildPlazaListSearchParams, '/campanha/pracas', page) export const resolvePlazaListUrl = ( - params: RawSearchParams, + params: PlazaListSearchParams, totalPages?: number, ): { state: PlazaListState diff --git a/tests/int/plazaMapData.int.spec.ts b/tests/int/plazaMapData.int.spec.ts new file mode 100644 index 00000000..ab9c947e --- /dev/null +++ b/tests/int/plazaMapData.int.spec.ts @@ -0,0 +1,75 @@ +// @vitest-environment node + +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { beforeAll, describe, expect, it } from 'vitest' + +import config from '@/payload.config' +import { plazaCatalog } from '@/lib/plazaCatalog' +import { loadPlazaMapBundle } from '@/utilities/plazaMapData' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' + +const ZONE_PLAZA_COUNT = plazaCatalog.filter((entry) => entry.kind === 'zona').length +const ZONE_MUNICIPALITY_CODES = new Set( + plazaCatalog.filter((entry) => entry.kind === 'zona').map((entry) => entry.ibgeCode), +) + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +describe('loadPlazaMapBundle — list URL filters', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('scopes the map to zone plazas when kind=zona is in the URL', async () => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + + const bundle = await loadPlazaMapBundle(payload, coordinator, { kind: 'zona' }) + + expect(bundle).not.toBeNull() + expect(bundle!.zoneBreakdown).toHaveLength(ZONE_PLAZA_COUNT) + + const ibgeCodes = Object.keys(bundle!.valuesByYear['2022'] ?? {}) + expect(ibgeCodes.length).toBeGreaterThan(0) + for (const code of ibgeCodes) { + expect(ZONE_MUNICIPALITY_CODES.has(code)).toBe(true) + } + }) + + it('returns null when the filtered plaza set is empty', async () => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + + const bundle = await loadPlazaMapBundle(payload, coordinator, { q: 'zzznomatch' }) + + expect(bundle).toBeNull() + }) + + it('keeps advisor access and applies URL filters on top', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const administered = await fixtures.getPlaza() + await fixtures.assignPlazaAdvisors(administered.id, [advisor.id]) + + const included = await loadPlazaMapBundle(payload, advisor, { q: administered.name }) + expect(included).not.toBeNull() + expect(included!.zoneBreakdown.some((row) => row.slug === administered.slug)).toBe( + administered.kind === 'zona', + ) + if (administered.kind !== 'zona') { + expect(Object.keys(included!.valuesByYear['2022'] ?? {})).toEqual([administered.ibgeCode]) + } + + const excluded = + administered.kind === 'zona' + ? await loadPlazaMapBundle(payload, advisor, { kind: 'municipio' }) + : await loadPlazaMapBundle(payload, advisor, { kind: 'zona' }) + expect(excluded).toBeNull() + }) +})