mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Deliver A9+ shared Praças list loader and scoped revalidation.
Deduplicate pledge aggregation via loadPlazaListPageBundle, add plazaRevalidation with slug-scoped detail paths after inline saves, and document the fill-in as shipped.
This commit is contained in:
parent
9db80d815b
commit
0dcef662e7
23 changed files with 441 additions and 190 deletions
|
|
@ -7,11 +7,12 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **A9+ entregue em código (2026-07-21; deploy pendente com remodelagem):** loader compartilhado da lista — `loadPlazaListPageBundle` (1× `aggregatePledgesByPlaza`), `buildPlazaMapBundleFromPlazas`, `plazaRevalidation.ts` + hidden `plazaSlug`; int `plazaPageData.int.spec.ts`. Plano: [`escala-dry-pos-a9.md`](../../../docs/plans/escala-dry-pos-a9.md). Débito restante: twin `listFormActions` ↔ `/editar` → **C8** F4.
|
||||
- **B11 entregue em código (2026-07-21; deploy pendente com remodelagem):** escala **% dos válidos** no Mapa das Praças — seletor `Total (votos)` / `% dos válidos` (default %); domínio 0–100% fixo (`scaleMax` em `BahiaMap`); `validVotesByYear` no bundle; 2026 usa válidos 2022; compare desliga %; readout em % + votos absolutos; `Promise.all` nos loads TSE. Plano: [`escala-percentual-mapa-pracas.md`](../../../docs/plans/escala-percentual-mapa-pracas.md). Débito perf troca `scaleMode` → **B6** [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md).
|
||||
- **Fill-in filtros-auto entregue em código (2026-07-21; deploy pendente com remodelagem):** debounce 1s + Enter imediato na busca de `/campanha/pracas`; remove Buscar; pending UI alinhado a `ActionPlanFilters`; `shouldUpdatePlazaSearchUrl` + no-op `buildPlazaFiltersKey`. Plano: [`filtros-auto-pracas.md`](../../../docs/plans/filtros-auto-pracas.md). Débitos pós-`/simplify` (sync back/forward, shell pending) → gatilhos no plano.
|
||||
- **B10 entregue em código (2026-07-21; deploy pendente com remodelagem):** hover/tap no Mapa das Praças — destaque + `MapFeatureReadout`; desktop click navega; mobile 2º tap; SSA/CMS N>1 foca `zoneBreakdown`; `plazasByIbgeCode` / `resolvePlazaMapNavigation`. Plano: [`hover-mapa-pracas.md`](../../../docs/plans/hover-mapa-pracas.md). Débito perf hover O(n) → **B6** [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md).
|
||||
- **B9 entregue em código (2026-07-21; deploy pendente com remodelagem):** edição rápida na lista — Assessores / Tendência / `expectedVotes` via `PlazaList*Control` + `listFormActions`; sem migration. Plano: [`edicao-rapida-lista-pracas.md`](../../../docs/plans/edicao-rapida-lista-pracas.md). Débitos `/simplify` → **A9+** F2 + **C8** F4.
|
||||
- **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) (loader compartilhado + revalidate escopada pós-B9).
|
||||
- **B9 entregue em código (2026-07-21; deploy pendente com remodelagem):** edição rápida na lista — Assessores / Tendência / `expectedVotes` via `PlazaList*Control` + `listFormActions`; sem migration. Plano: [`edicao-rapida-lista-pracas.md`](../../../docs/plans/edicao-rapida-lista-pracas.md). Débitos `/simplify` → **C8** F4 (prep revalidate/parsing no A9+).
|
||||
- **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).
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ Componentes:
|
|||
|
||||
Limpezas aplicadas no cleanup da sessão (não reabrir): `politicalTrend.note` via hidden field (sem `findByID` extra); `politicalTrendBadgeVariant` em `plazaUi.ts`; `PlazaAdvisorAvatarStack`; tipos `PoliticalTrendStatus`; `max={1_000_000}` no input; higiene de testes.
|
||||
|
||||
Débitos absorvidos noutros planos: twin `listFormActions` ↔ `/editar` → **C8 F4** ([escala-dry-pos-c6.md](escala-dry-pos-c6.md)); `revalidatePath` full-page pós-save → **A9+ F2** ([escala-dry-pos-a9.md](escala-dry-pos-a9.md)).
|
||||
Débitos absorvidos noutros planos: twin `listFormActions` ↔ `/editar` → **C8 F4** ([escala-dry-pos-c6.md](escala-dry-pos-c6.md)). ~~`revalidatePath` full-page pós-save~~ → **A9+ F2 entregue** ([escala-dry-pos-a9.md](escala-dry-pos-a9.md): `plazaRevalidation.ts` + `plazaSlug`).
|
||||
|
||||
**Explicitamente fora (triage):** lazy-load `getEligibleAdvisorOptions`; `PopoverAnchor` não usado; helper `parsePoliticalTrendStatus`; unit tests dos `PlazaList*Control`.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Escala e DRY pós-A9 (loader da lista de Praças)
|
||||
|
||||
Status: registrado no roadmap (Fase 1 pendente; **B7 entregue 2026-07-21** — prioridade sobe)
|
||||
Atualizado em: 2026-07-21 (`capture-review-debts` pós-B11 `/simplify`: `Promise.all` TSE em `loadPlazaMapBundle` resolvido; pós-B7 + pós-B9)
|
||||
Status: entregue 2026-07-21 (F1+F2; aguardando merge/deploy com remodelagem)
|
||||
Atualizado em: 2026-07-21 (implementação + `/simplify` + rebase em `main` com B11; `capture-review-debts` pós-implementação)
|
||||
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: ~1–1,5 dia eng (F1 loader + F2 revalidate pós-B9; PR único ou dois commits)
|
||||
|
|
@ -13,13 +13,23 @@ O A9 ([estimativa-votos-praca.md](estimativa-votos-praca.md)) entrega `plaza.exp
|
|||
|
||||
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.
|
||||
O revisor de **performance** marcou como **maior que simplify** os três problemas abaixo (contexto pré-A9+). **Resolvidos na entrega F1+F2:**
|
||||
|
||||
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.
|
||||
1. ~~**Query triplicada de pledges**~~ — `loadPlazaListPageBundle` chama `aggregatePledgesByPlaza` uma vez; overview, mapa e coluna da página reutilizam o mesmo `Map`.
|
||||
2. **Dois `find` de Praças no mesmo filtro** — mantido de propósito (paginado + conjunto filtrado inteiro); ver “Questões em aberto”.
|
||||
3. ~~**Revalidação full-page sem escopo no detalhe**~~ — `revalidatePlazaListPaths({ slug })` via hidden `plazaSlug`; lista ainda revalida full-page (filtros `?trend=` + KPIs).
|
||||
|
||||
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).
|
||||
## Como entregue (2026-07-21)
|
||||
|
||||
3. **Revalidação full-page após edição inline (B9).** Cada save em `listFormActions.ts` chama `revalidatePath('/campanha/pracas', 'page')` (+ detalhe), o que reroda overview unpaginado, mapa e lista — custo amplificado quando o coordenador edita várias células seguidas (pós-B9).
|
||||
**F1 — Loader:** `loadPlazaListPageBundle` em `plazaPageData.ts`; `buildPlazaMapBundleFromPlazas` + `scopePlazasFromDocs` em `plazaMapData.ts`; `page.tsx` com uma única chamada; removidos `loadPlazaListPageData` / `loadPlazaListOverviewData` do export público.
|
||||
|
||||
**F2 — Revalidate:** `plazaRevalidation.ts` (`revalidatePlazaListPaths` com validação de slug); hidden `plazaSlug` em `PlazaList*Control`, `PlazaStrategyForm`, `PlazaAdvisorsForm`; `listFormActions` + `editar/formActions` atualizados.
|
||||
|
||||
**Helpers compartilhados (prep C8 F4):** `optionalPlazaSlugFromForm`, `parsePoliticalTrendStatusFormValue`.
|
||||
|
||||
**Testes:** `tests/int/plazaPageData.int.spec.ts` (4 casos: vazio, rollup, advisor+filtro, leader sem mapa/overview).
|
||||
|
||||
**Validação:** `tsc --noEmit`; int `plazaPageData` + `plazaMapData` verdes; Aikido 0 findings nos arquivos editados.
|
||||
|
||||
**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`). **Pós-B11 simplify:** `Promise.all` nos loads TSE por ano em `loadPlazaMapBundle` (votes + válidos em paralelo; branch `?compare=` também paraleliza os 3 `loadCandidateVotesByCityZone`) — micro-opt que estava fora do escopo A9+; resolvido no cleanup B11, não reabrir aqui.
|
||||
|
||||
|
|
@ -29,6 +39,16 @@ O revisor de **performance** marcou como **maior que simplify** o follow-up abai
|
|||
|
||||
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B9):** ~75 hooks `useActionState` por página (3 controles × 25 linhas × 2 views) — gatilho: reclamação de perf ou **R6**; merge `listFormActions` ↔ `/editar` → **C8 F4**; hook popover compartilhado / layout responsivo único / lazy advisor options → gatilhos no plano B9.
|
||||
|
||||
## Simplify pós-implementação (2026-07-21)
|
||||
|
||||
Passagem `/simplify` sobre o diff A9+ (F1+F2) após rebase em `main` com B11.
|
||||
|
||||
**Já resolvido (não reabrir):** `optionalPlazaSlugFromForm` (`formData.ts`); `parsePoliticalTrendStatusFormValue` (`schemas/plaza.ts`); colapso `needsFilteredSet` → `isCampaignStaff`; remoção de `declaredVotesTotal` / `highPriorityCount` mortos em `PlazaListOverviewData`; validação de slug em `revalidatePlazaListPaths`; `compareCandidate` no branch `?compare=` de `buildPlazaMapBundleFromPlazas`; int `plazaPageData` sem re-query redundante de pledges.
|
||||
|
||||
**F2 como entregue:** `plazaRevalidation.ts` + hidden `plazaSlug` nos forms; lista e `/editar` chamam `revalidatePlazaListPaths({ slug })` — revalida o detalhe de forma estreita quando o slug está presente; a lista continua com `revalidatePath` full-page (paridade com filtros `?trend=` e KPIs). **Rejeitado neste fill-in:** cache por tag / matriz `scope` por tipo de save.
|
||||
|
||||
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-implementação A9+):** `scope: 'detail'` só em save de tendência (a lista precisa refletir filtro `?trend=`); cache/revalidate por tag (rabbit hole); mover `plazaListFilteredSelect` → `plazaViewModels` (gatilho: 3º consumidor); paralelizar pledge aggregate + loads TSE no mesmo tick do bundle (gatilho: profiling TTFB); unificar os dois `find` de Praças (decisão travada em “Questões em aberto”); twin completo `listFormActions` ↔ `/editar` → **C8 F4** (prep parcial — ver [escala-dry-pos-c6.md](escala-dry-pos-c6.md) Fase 4).
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Abrir `/campanha/pracas` com filtro amplo não executa **três** `aggregatePledgesByPlaza` independentes quando um mapa de agregados compartilhado basta.
|
||||
|
|
@ -39,7 +59,7 @@ O revisor de **performance** marcou como **maior que simplify** o follow-up abai
|
|||
|
||||
## 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.
|
||||
- **Um fill-in A9+, duas fases ordenadas (F1 loader + F2 revalidate).** 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; **menos cortável desde B7 entregue** (mapa filtrado + sempre visível para staff amplifica o custo do triplo agregado quando o filtro é amplo).
|
||||
|
|
@ -47,7 +67,7 @@ O revisor de **performance** marcou como **maior que simplify** o follow-up abai
|
|||
|
||||
## 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.
|
||||
- **Bundle único vs compartilhar só o mapa de pledges?** **Resolvido (2026-07-21):** opção (A) — `loadPlazaListPageBundle` com um `find` unpaginado filtrado + um `aggregatePledgesByPlaza`; `buildPlazaMapBundleFromPlazas` recebe agregados pré-computados.
|
||||
- **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
|
||||
|
|
@ -108,15 +128,20 @@ 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.
|
||||
- **Paralelizar pledge aggregate + loads TSE históricos no bundle.** Revisitar se profiling mostrar TTFB dominado pelo mapa com filtro amplo (anos TSE já paralelizam dentro de `buildPlazaMapBundleFromPlazas` desde B11/A9+ simplify).
|
||||
- **Cache/revalidate por tag ou `scope` por tipo de save.** Revisitar se N saves inline seguidos na lista mostrarem custo de rerender inaceitável em campo.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (Trilha A, A9 entregue + fill-in A9+)
|
||||
- `docs/roadmap.md` (Trilha A, A9+ entregue 2026-07-21)
|
||||
- `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/app/(campaign)/campanha/(app)/pracas/listFormActions.ts` — `revalidatePlazaListPaths` pós-B9
|
||||
- `src/utilities/plazaPageData.ts` — `loadPlazaListPageData`, `loadPlazaListOverviewData`
|
||||
- `src/app/(campaign)/campanha/(app)/pracas/page.tsx` — consome `loadPlazaListPageBundle`
|
||||
- `src/app/(campaign)/campanha/(app)/pracas/listFormActions.ts` — `revalidatePlazaListPaths` com `plazaSlug`
|
||||
- `src/utilities/plazaPageData.ts` — `loadPlazaListPageBundle`
|
||||
- `src/utilities/plazaMapData.ts` — `buildPlazaMapBundleFromPlazas`, `scopePlazasFromDocs`
|
||||
- `src/utilities/plazaRevalidation.ts` — helper compartilhado (prep C8 F4)
|
||||
- `tests/int/plazaPageData.int.spec.ts` — paridade overview/mapa/escopo
|
||||
- `src/utilities/plazaMapData.ts` — `loadPlazaMapBundle`, métrica 2026
|
||||
- `src/utilities/votePledgeData.ts` — `aggregatePledgesByPlaza`, `rollupPlazaStaffVotes`
|
||||
- AGENTS.md — naming EN/pt-BR; access staff; sem PII
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ flowchart TD
|
|||
### Fase 4 — DRY de forms
|
||||
|
||||
- Migrar os `formActions.ts` restantes para `mapCampaignFormActionError` + `campaignFormFields` (hoje o ladder `FormDataBoundaryError` → `ZodError` → `validationFieldErrors` → safe-message está inlined em vários arquivos):
|
||||
- `src/app/(campaign)/campanha/(app)/pracas/listFormActions.ts` + `pracas/[slug]/editar/formActions.ts` — **twin paths pós-B9** (mesmo parsing `formData`, mesmas actions de domínio, `revalidatePlazaListPaths` duplicado vs `revalidatePath` por action no `/editar`; extrair helper compartilhado de revalidate + wrappers finos por superfície)
|
||||
- `src/app/(campaign)/campanha/(app)/pracas/listFormActions.ts` + `pracas/[slug]/editar/formActions.ts` — **twin paths pós-B9** (mesmo parsing `formData`, mesmas actions de domínio). **Prep A9+ (2026-07-21):** `revalidatePlazaListPaths` (`plazaRevalidation.ts`), `optionalPlazaSlugFromForm`, `parsePoliticalTrendStatusFormValue` — feitos; **falta** merge dos wrappers de server action (4 actions × 2 superfícies).
|
||||
- `src/app/(campaign)/campanha/(app)/pracas/[slug]/updateFormActions.ts`, `pledgeFormActions.ts` (se ainda não no mapper)
|
||||
- `src/app/(campaign)/campanha/convite/[token]/formActions.ts`
|
||||
- `src/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions.ts`
|
||||
|
|
@ -136,4 +136,8 @@ Os débitos que os revisores marcaram como importantes e maiores que cleanup for
|
|||
|
||||
`/simplify` da entrega **B9** ([edicao-rapida-lista-pracas.md](edicao-rapida-lista-pracas.md)) marcou como **maior que cleanup** o twin `listFormActions.ts` ↔ `editar/formActions.ts` — absorvido na **Fase 4** acima. B9 já adota `mapCampaignFormActionError`; o débito é DRY de parsing/revalidate entre lista e `/editar`, não introdução do mapper.
|
||||
|
||||
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B9):** hook `usePlazaListPopoverForm` (gatilho: 4º inline editor); `PlazaAdvisorCheckboxList` (gatilho: 3º uso ou refactor do form `/editar`); layout responsivo único mobile+desktop na lista (gatilho: page size >25 ou profiling de hydration); lazy-load de `getEligibleAdvisorOptions` (staff set pequeno); `PopoverAnchor` export não usado (scaffold shadcn); helper `parsePoliticalTrendStatus`; unit tests dos `PlazaList*Control` (int access cobre domínio); E2E save inline (gatilho: smoke pós-merge B9).
|
||||
**Explicitamente fora (triage `capture-review-debts` 2026-07-21 pós-B9):** hook `usePlazaListPopoverForm` (gatilho: 4º inline editor); `PlazaAdvisorCheckboxList` (gatilho: 3º uso ou refactor do form `/editar`); layout responsivo único mobile+desktop na lista (gatilho: page size >25 ou profiling de hydration); lazy-load de `getEligibleAdvisorOptions` (staff set pequeno); `PopoverAnchor` export não usado (scaffold shadcn); ~~helper `parsePoliticalTrendStatus`~~ → entregue como `parsePoliticalTrendStatusFormValue` no prep A9+; unit tests dos `PlazaList*Control` (int access cobre domínio); E2E save inline (gatilho: smoke pós-merge B9).
|
||||
|
||||
## Simplify (2026-07-21 pós-A9+)
|
||||
|
||||
Triage `capture-review-debts` após `/simplify` do fill-in A9+: prep da **Fase 4** nos twins de Praças — `plazaRevalidation.ts`, `optionalPlazaSlugFromForm`, `parsePoliticalTrendStatusFormValue` (lista + `/editar`). Débito restante na Fase 4: unificar os wrappers de server action duplicados; não reabrir revalidate/parsing.
|
||||
|
|
|
|||
|
|
@ -111,14 +111,14 @@ Componentes:
|
|||
|
||||
## 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`).
|
||||
Fill-in **A9+** entregue 2026-07-21 — [escala-dry-pos-a9.md](escala-dry-pos-a9.md) (`loadPlazaListPageBundle`, 1× `aggregatePledgesByPlaza`, `plazaRevalidation.ts`). Débito restante: twin `listFormActions` ↔ `/editar` → **C8** F4.
|
||||
|
||||
**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)
|
||||
- `docs/plans/escala-dry-pos-a9.md` — A9+ entregue (loader lista + revalidate escopada)
|
||||
- `src/collections/Plaza.ts` — `voteGoals`, access staff
|
||||
- `src/utilities/votePledgeData.ts` — `effectiveTotal`
|
||||
- `src/utilities/plazaMapData.ts` — métrica 2026
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ Impeccable: B — encaixe em `/campanha/pracas` (`PlazaMapPanel` + loader; sem r
|
|||
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+**._
|
||||
_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+ entregue** (`buildPlazaMapBundleFromPlazas` recebe agregados do bundle da lista)._
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-21 (fill-in filtros-auto + B11 entregues em código; B9 + B10 entregues; débitos B9 → A9+/C8 F4; débitos B10/B11 scale toggle → B6)
|
||||
Atualizado em: 2026-07-21 (A9+ entregue em código; fill-in filtros-auto + B11 entregues; B9 + B10 entregues; débitos B9 → C8 F4; débitos B10/B11 scale toggle → B6)
|
||||
|
||||
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).
|
||||
|
||||
|
|
@ -69,7 +69,8 @@ flowchart TD
|
|||
R4 -.baseline por Praça.-> A6
|
||||
R2 --> D2["D2 Push + sino"]
|
||||
R2 --> A9["A9 Estimativa votos ✓"]
|
||||
A9 --> B9["B9 Edição rápida lista ✓"]
|
||||
A9 --> A9plus["A9+ Loader lista ✓"]
|
||||
A9plus --> B9["B9 Edição rápida lista ✓"]
|
||||
R2 -.-> B7["B7 Mapa filtrado ✓"]
|
||||
R2 -.-> B8["B8 Polígonos Praças-zona<br/>(SSA/CMS)"]
|
||||
R2 -.-> B10["B10 Hover/tap mapa ✓"]
|
||||
|
|
@ -82,7 +83,7 @@ flowchart TD
|
|||
C2prod --> C5["C5 GOTV (validar)"]
|
||||
```
|
||||
|
||||
Paralelizáveis agora: fill-ins da lista (A9+, …). ~~**A9** / **B9** / **B7** / **B10** / **B11** / **filtros-auto**~~ entregues 2026-07-21. **B6** absorve hot path de hover pós-B10 e troca de escala pós-B11 (Janela 3 / gatilho de densidade).
|
||||
Paralelizáveis agora: fill-ins da lista (**C8** F4, …). ~~**A9** / **A9+** / **B9** / **B7** / **B10** / **B11** / **filtros-auto**~~ entregues 2026-07-21. **B6** absorve hot path de hover pós-B10 e troca de escala pós-B11 (Janela 3 / gatilho de densidade).
|
||||
|
||||
### Sequência por janela
|
||||
|
||||
|
|
@ -105,9 +106,10 @@ Paralelizáveis agora: fill-ins da lista (A9+, …). ~~**A9** / **B9** / **B7**
|
|||
- **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) (loader compartilhado + revalidate escopada pós-B9).
|
||||
- **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).
|
||||
- **A9+ (2026-07-21)** — **Loader compartilhado da lista de Praças** (`loadPlazaListPageBundle`: 1× `aggregatePledgesByPlaza` + `buildPlazaMapBundleFromPlazas`; `plazaRevalidation.ts` + `plazaSlug` para revalidate estreita no detalhe; int `plazaPageData.int.spec.ts`; sem migration) — [plano](plans/escala-dry-pos-a9.md). Débito restante pós-`/simplify`: twin completo `listFormActions` ↔ `/editar` → **C8** F4.
|
||||
- **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).
|
||||
- **B9 (2026-07-21)** — **Edição rápida na lista de Praças** (Assessores / Tendência / `expectedVotes` via Popovers em `PlazaList*Control`; `listFormActions`; sem migration) — [plano](plans/edicao-rapida-lista-pracas.md). Débitos `/simplify`: **A9+** F2 (revalidate) + **C8** F4 (DRY formActions).
|
||||
- **B9 (2026-07-21)** — **Edição rápida na lista de Praças** (Assessores / Tendência / `expectedVotes` via Popovers em `PlazaList*Control`; `listFormActions`; sem migration) — [plano](plans/edicao-rapida-lista-pracas.md). Débitos `/simplify`: **C8** F4 (DRY formActions; prep revalidate/parsing feito no A9+).
|
||||
- **B10 (2026-07-21)** — **Hover/tap no Mapa das Praças** (destaque + `MapFeatureReadout`; desktop click navega; mobile 2º tap; SSA/CMS N>1 → `zoneBreakdown`; `plazasByIbgeCode` / `resolvePlazaMapNavigation`) — [plano](plans/hover-mapa-pracas.md). Débito perf hover O(n) → **B6** [escala-dry-pos-b3.md](plans/escala-dry-pos-b3.md).
|
||||
- **B11 (2026-07-21)** — **Escala % dos válidos no Mapa das Praças** (`validVotesByYear` no bundle; seletor `Total (votos)` / `% dos válidos`; domínio fixo 0–100%; 2026 usa válidos 2022; compare desliga %; readout em %) — [plano](plans/escala-percentual-mapa-pracas.md). Débito perf troca `scaleMode` → **B6** [escala-dry-pos-b3.md](plans/escala-dry-pos-b3.md).
|
||||
- **Fill-in filtros-auto (2026-07-21)** — **Filtros auto-aplicados na lista de Praças** (`PlazaFilters`: debounce 1s no `q`, Enter imediato, selects imediatos, remove Buscar; `useTransition` + pending a11y; `shouldUpdatePlazaSearchUrl` + no-op via `buildPlazaFiltersKey`; sem migration) — [plano](plans/filtros-auto-pracas.md). Débitos pós-`/simplify`: sync back/forward `search`↔`state.q` e shell pending compartilhado — gatilhos no plano.
|
||||
|
|
@ -137,7 +139,7 @@ Paralelizáveis agora: fill-ins da lista (A9+, …). ~~**A9** / **B9** / **B7**
|
|||
- **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)
|
||||
- **Fill-ins:** **A9+** loader compartilhado lista/mapa + revalidate escopada pós-B9 ([plano](plans/escala-dry-pos-a9.md)) · **C8** F4 DRY `listFormActions`↔`/editar` ([plano](plans/escala-dry-pos-c6.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:** **C8** F4 DRY `listFormActions`↔`/editar` ([plano](plans/escala-dry-pos-c6.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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
import {
|
||||
assignPlazaAdvisors,
|
||||
setPlazaExpectedVotes,
|
||||
|
|
@ -12,15 +10,17 @@ import {
|
|||
nullableFormText,
|
||||
optionalFormText,
|
||||
optionalIntegerFormValue,
|
||||
optionalPlazaSlugFromForm,
|
||||
repeatedFormTexts,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import { politicalTrendStatuses, type PoliticalTrendStatusValue } from '@/lib/schemas/plaza'
|
||||
import { parsePoliticalTrendStatusFormValue } from '@/lib/schemas/plaza'
|
||||
import {
|
||||
mapCampaignFormActionError,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
import { revalidatePlazaListPaths } from '@/utilities/plazaRevalidation'
|
||||
|
||||
export const updatePlazaStrategyFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
|
|
@ -43,7 +43,7 @@ export const updatePlazaStrategyFormAction = async (
|
|||
dobradinhaNotes: nullableFormText(formData, 'dobradinhaNotes'),
|
||||
nextSteps: nullableFormText(formData, 'nextSteps'),
|
||||
})
|
||||
revalidatePath('/campanha/pracas/[slug]', 'page')
|
||||
revalidatePlazaListPaths({ slug: optionalPlazaSlugFromForm(formData) })
|
||||
return { status: 'success', message: 'Estratégia atualizada.' }
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
|
|
@ -71,8 +71,7 @@ export const setPlazaExpectedVotesFormAction = async (
|
|||
}) ?? null
|
||||
|
||||
await setPlazaExpectedVotes({ plaza, expectedVotes })
|
||||
revalidatePath('/campanha/pracas', 'page')
|
||||
revalidatePath('/campanha/pracas/[slug]', 'page')
|
||||
revalidatePlazaListPaths({ slug: optionalPlazaSlugFromForm(formData) })
|
||||
return { status: 'success', message: 'Votos estimados atualizados.' }
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
|
|
@ -90,17 +89,14 @@ export const setPlazaPoliticalTrendFormAction = async (
|
|||
): Promise<CampaignFormActionState> => {
|
||||
try {
|
||||
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
|
||||
const rawStatus = optionalFormText(formData, 'trendStatus')
|
||||
const status = politicalTrendStatuses.includes(rawStatus as PoliticalTrendStatusValue)
|
||||
? (rawStatus as PoliticalTrendStatusValue)
|
||||
: null
|
||||
const status = parsePoliticalTrendStatusFormValue(optionalFormText(formData, 'trendStatus'))
|
||||
|
||||
await setPlazaPoliticalTrend({
|
||||
plaza,
|
||||
status,
|
||||
note: nullableFormText(formData, 'trendNote'),
|
||||
})
|
||||
revalidatePath('/campanha/pracas/[slug]', 'page')
|
||||
revalidatePlazaListPaths({ slug: optionalPlazaSlugFromForm(formData) })
|
||||
return { status: 'success', message: 'Tendência política registrada.' }
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
|
|
@ -121,7 +117,7 @@ export const assignPlazaAdvisorsFormAction = async (
|
|||
const advisors = repeatedRelationshipFormValues(formData, 'advisors')
|
||||
|
||||
await assignPlazaAdvisors({ plaza, advisors })
|
||||
revalidatePath('/campanha/pracas/[slug]', 'page')
|
||||
revalidatePlazaListPaths({ slug: optionalPlazaSlugFromForm(formData) })
|
||||
return { status: 'success', message: 'Assessores atualizados.' }
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export default async function PlazaEditPage({ params }: PlazaEditPageProps) {
|
|||
{user.role === 'coordinator' ? (
|
||||
<PlazaAdvisorsForm
|
||||
plazaID={view.id}
|
||||
plazaSlug={view.slug}
|
||||
currentAdvisorIDs={view.advisorIDs}
|
||||
options={advisorOptions}
|
||||
formAction={assignPlazaAdvisorsFormAction}
|
||||
|
|
@ -74,6 +75,7 @@ export default async function PlazaEditPage({ params }: PlazaEditPageProps) {
|
|||
|
||||
<PlazaStrategyForm
|
||||
plazaID={view.id}
|
||||
plazaSlug={view.slug}
|
||||
strategy={view.strategy}
|
||||
strategyFormAction={updatePlazaStrategyFormAction}
|
||||
expectedVotesFormAction={setPlazaExpectedVotesFormAction}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
import {
|
||||
assignPlazaAdvisors,
|
||||
setPlazaExpectedVotes,
|
||||
|
|
@ -11,19 +9,16 @@ import {
|
|||
nullableFormText,
|
||||
optionalFormText,
|
||||
optionalIntegerFormValue,
|
||||
optionalPlazaSlugFromForm,
|
||||
repeatedRelationshipFormValues,
|
||||
requiredRelationshipFormValue,
|
||||
} from '@/lib/formData'
|
||||
import { politicalTrendStatuses, type PoliticalTrendStatusValue } from '@/lib/schemas/plaza'
|
||||
import { parsePoliticalTrendStatusFormValue } from '@/lib/schemas/plaza'
|
||||
import {
|
||||
mapCampaignFormActionError,
|
||||
type CampaignFormActionState,
|
||||
} from '@/utilities/campaignFormActionError'
|
||||
|
||||
const revalidatePlazaListPaths = () => {
|
||||
revalidatePath('/campanha/pracas', 'page')
|
||||
revalidatePath('/campanha/pracas/[slug]', 'page')
|
||||
}
|
||||
import { revalidatePlazaListPaths } from '@/utilities/plazaRevalidation'
|
||||
|
||||
export const setPlazaExpectedVotesListFormAction = async (
|
||||
_state: CampaignFormActionState,
|
||||
|
|
@ -38,7 +33,7 @@ export const setPlazaExpectedVotesListFormAction = async (
|
|||
}) ?? null
|
||||
|
||||
await setPlazaExpectedVotes({ plaza, expectedVotes })
|
||||
revalidatePlazaListPaths()
|
||||
revalidatePlazaListPaths({ slug: optionalPlazaSlugFromForm(formData) })
|
||||
return { status: 'success', message: 'Votos estimados atualizados.' }
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
|
|
@ -56,17 +51,14 @@ export const setPlazaPoliticalTrendListFormAction = async (
|
|||
): Promise<CampaignFormActionState> => {
|
||||
try {
|
||||
const plaza = requiredRelationshipFormValue(formData, 'plazaId')
|
||||
const rawStatus = optionalFormText(formData, 'trendStatus')
|
||||
const status = politicalTrendStatuses.includes(rawStatus as PoliticalTrendStatusValue)
|
||||
? (rawStatus as PoliticalTrendStatusValue)
|
||||
: null
|
||||
const status = parsePoliticalTrendStatusFormValue(optionalFormText(formData, 'trendStatus'))
|
||||
|
||||
await setPlazaPoliticalTrend({
|
||||
plaza,
|
||||
status,
|
||||
note: nullableFormText(formData, 'trendNote'),
|
||||
})
|
||||
revalidatePlazaListPaths()
|
||||
revalidatePlazaListPaths({ slug: optionalPlazaSlugFromForm(formData) })
|
||||
return { status: 'success', message: 'Tendência política registrada.' }
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
|
|
@ -87,7 +79,7 @@ export const assignPlazaAdvisorsListFormAction = async (
|
|||
const advisors = repeatedRelationshipFormValues(formData, 'advisors')
|
||||
|
||||
await assignPlazaAdvisors({ plaza, advisors })
|
||||
revalidatePlazaListPaths()
|
||||
revalidatePlazaListPaths({ slug: optionalPlazaSlugFromForm(formData) })
|
||||
return { status: 'success', message: 'Assessores atualizados.' }
|
||||
} catch (error) {
|
||||
return mapCampaignFormActionError({
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ import {
|
|||
import { isCampaignCoordinator, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { getCampaignUser } from '@/utilities/campaignAuth'
|
||||
import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions'
|
||||
import { loadPlazaMapBundle } from '@/utilities/plazaMapData'
|
||||
import { loadPlazaListOverviewData, loadPlazaListPageData } from '@/utilities/plazaPageData'
|
||||
import { loadPlazaListPageBundle } from '@/utilities/plazaPageData'
|
||||
import {
|
||||
buildPlazaFiltersKey,
|
||||
buildPlazaListHref,
|
||||
|
|
@ -56,18 +55,18 @@ export default async function PlazasPage({ searchParams }: PlazasPageProps) {
|
|||
const isStaffView = isCampaignStaff(user)
|
||||
const isCoordinator = isCampaignCoordinator(user)
|
||||
|
||||
const [listData, overview, mapBundle] = await Promise.all([
|
||||
loadPlazaListPageData(payload, user, rawSearchParams),
|
||||
loadPlazaListOverviewData(payload, user, rawSearchParams),
|
||||
loadPlazaMapBundle(payload, user, rawSearchParams),
|
||||
])
|
||||
const candidateOptions = mapBundle ? await loadFederalCandidateOptions(payload, user) : []
|
||||
const resolvedUrl = resolvePlazaListUrl(rawSearchParams, listData.totalPages)
|
||||
const pageBundle = await loadPlazaListPageBundle(payload, user, rawSearchParams)
|
||||
const candidateOptions = pageBundle.mapBundle
|
||||
? await loadFederalCandidateOptions(payload, user)
|
||||
: []
|
||||
const { plazas: listPlazas, totalDocs, totalPages, scopeTotal, overview, mapBundle } =
|
||||
pageBundle
|
||||
const resolvedUrl = resolvePlazaListUrl(rawSearchParams, totalPages)
|
||||
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
|
||||
const { state } = resolvedUrl
|
||||
const listVisitLabel = buildPlazaListVisitLabel(state)
|
||||
|
||||
const advisorIDs = [...new Set(listData.plazas.flatMap((plaza) => plaza.advisorIDs))]
|
||||
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 =
|
||||
|
|
@ -82,7 +81,7 @@ export default async function PlazasPage({ searchParams }: PlazasPageProps) {
|
|||
cada.
|
||||
</p>
|
||||
<CampaignScopeBadge>
|
||||
{getCampaignScopeLabel(user.role, listData.scopeTotal)}
|
||||
{getCampaignScopeLabel(user.role, scopeTotal)}
|
||||
</CampaignScopeBadge>
|
||||
</header>
|
||||
|
||||
|
|
@ -96,11 +95,11 @@ export default async function PlazasPage({ searchParams }: PlazasPageProps) {
|
|||
showStaffFilters={isStaffView}
|
||||
/>
|
||||
|
||||
{listData.plazas.length ? (
|
||||
{listPlazas.length ? (
|
||||
<>
|
||||
{overview ? <PlazaListOverview view={overview} /> : null}
|
||||
<PlazaList
|
||||
plazas={listData.plazas}
|
||||
plazas={listPlazas}
|
||||
advisorNamesById={advisorNamesById}
|
||||
isStaffView={isStaffView}
|
||||
isCoordinator={isCoordinator}
|
||||
|
|
@ -111,12 +110,12 @@ export default async function PlazasPage({ searchParams }: PlazasPageProps) {
|
|||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{listData.totalDocs}{' '}
|
||||
{listData.totalDocs === 1 ? 'Praça encontrada' : 'Praças encontradas'}
|
||||
{totalDocs}{' '}
|
||||
{totalDocs === 1 ? 'Praça encontrada' : 'Praças encontradas'}
|
||||
</p>
|
||||
<CampaignListPagination
|
||||
page={state.page}
|
||||
totalPages={listData.totalPages}
|
||||
totalPages={totalPages}
|
||||
hrefForPage={(page) => buildPlazaListHref(state, page)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { EligibleAdvisorOption } from '@/utilities/plazaViewModels'
|
|||
|
||||
type PlazaAdvisorsFormProps = {
|
||||
plazaID: number
|
||||
plazaSlug: string
|
||||
currentAdvisorIDs: number[]
|
||||
options: EligibleAdvisorOption[]
|
||||
formAction: (
|
||||
|
|
@ -23,6 +24,7 @@ type PlazaAdvisorsFormProps = {
|
|||
/** Coordinator-only: assign the advisors responsible for this plaza. */
|
||||
export const PlazaAdvisorsForm = ({
|
||||
plazaID,
|
||||
plazaSlug,
|
||||
currentAdvisorIDs,
|
||||
options,
|
||||
formAction,
|
||||
|
|
@ -39,6 +41,7 @@ export const PlazaAdvisorsForm = ({
|
|||
</p>
|
||||
</div>
|
||||
<input type="hidden" name="plazaId" value={plazaID} />
|
||||
<input type="hidden" name="plazaSlug" value={plazaSlug} />
|
||||
<ul className="flex max-h-72 flex-col gap-1 overflow-y-auto">
|
||||
{options.map((option) => (
|
||||
<li key={option.id}>
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export const PlazaList = ({
|
|||
<dd>
|
||||
<PlazaListExpectedVotesControl
|
||||
plazaID={plaza.id}
|
||||
plazaSlug={plaza.slug}
|
||||
expectedVotes={plaza.expectedVotes}
|
||||
leadershipEffectiveTotal={plaza.pledges.effectiveTotal}
|
||||
formAction={expectedVotesFormAction}
|
||||
|
|
@ -100,6 +101,7 @@ export const PlazaList = ({
|
|||
<dd>
|
||||
<PlazaListTrendControl
|
||||
plazaID={plaza.id}
|
||||
plazaSlug={plaza.slug}
|
||||
status={plaza.politicalTrendStatus}
|
||||
trendNote={plaza.politicalTrendNote}
|
||||
formAction={trendFormAction}
|
||||
|
|
@ -112,6 +114,7 @@ export const PlazaList = ({
|
|||
{isCoordinator ? (
|
||||
<PlazaListAdvisorsControl
|
||||
plazaID={plaza.id}
|
||||
plazaSlug={plaza.slug}
|
||||
currentAdvisorIDs={plaza.advisorIDs}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
|
|
@ -184,6 +187,7 @@ export const PlazaList = ({
|
|||
{isCoordinator ? (
|
||||
<PlazaListAdvisorsControl
|
||||
plazaID={plaza.id}
|
||||
plazaSlug={plaza.slug}
|
||||
currentAdvisorIDs={plaza.advisorIDs}
|
||||
advisorNamesById={advisorNamesById}
|
||||
options={advisorOptions}
|
||||
|
|
@ -196,6 +200,7 @@ export const PlazaList = ({
|
|||
<TableCell>
|
||||
<PlazaListTrendControl
|
||||
plazaID={plaza.id}
|
||||
plazaSlug={plaza.slug}
|
||||
status={plaza.politicalTrendStatus}
|
||||
trendNote={plaza.politicalTrendNote}
|
||||
formAction={trendFormAction}
|
||||
|
|
@ -204,6 +209,7 @@ export const PlazaList = ({
|
|||
<TableCell>
|
||||
<PlazaListExpectedVotesControl
|
||||
plazaID={plaza.id}
|
||||
plazaSlug={plaza.slug}
|
||||
expectedVotes={plaza.expectedVotes}
|
||||
leadershipEffectiveTotal={plaza.pledges.effectiveTotal}
|
||||
formAction={expectedVotesFormAction}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type { EligibleAdvisorOption, PlazaAdvisorSummary } from '@/utilities/pla
|
|||
|
||||
type PlazaListAdvisorsControlProps = {
|
||||
plazaID: number
|
||||
plazaSlug: string
|
||||
currentAdvisorIDs: number[]
|
||||
advisorNamesById: ReadonlyMap<number, PlazaAdvisorSummary>
|
||||
options: EligibleAdvisorOption[]
|
||||
|
|
@ -26,6 +27,7 @@ type PlazaListAdvisorsControlProps = {
|
|||
|
||||
export const PlazaListAdvisorsControl = ({
|
||||
plazaID,
|
||||
plazaSlug,
|
||||
currentAdvisorIDs,
|
||||
advisorNamesById,
|
||||
options,
|
||||
|
|
@ -64,6 +66,7 @@ export const PlazaListAdvisorsControl = ({
|
|||
<PopoverContent align="start" className="w-80">
|
||||
<form action={submitAction} className="flex flex-col gap-3">
|
||||
<input type="hidden" name="plazaId" value={plazaID} />
|
||||
<input type="hidden" name="plazaSlug" value={plazaSlug} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
O assessor vê e gerencia somente as Praças que administra.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type { CampaignFormActionState } from '@/utilities/campaignFormActionErro
|
|||
|
||||
type PlazaListExpectedVotesControlProps = {
|
||||
plazaID: number
|
||||
plazaSlug: string
|
||||
expectedVotes: number | null
|
||||
leadershipEffectiveTotal: number
|
||||
formAction: (
|
||||
|
|
@ -24,6 +25,7 @@ type PlazaListExpectedVotesControlProps = {
|
|||
|
||||
export const PlazaListExpectedVotesControl = ({
|
||||
plazaID,
|
||||
plazaSlug,
|
||||
expectedVotes,
|
||||
leadershipEffectiveTotal,
|
||||
formAction,
|
||||
|
|
@ -55,6 +57,7 @@ export const PlazaListExpectedVotesControl = ({
|
|||
<PopoverContent align="start" className="w-72">
|
||||
<form action={submitAction} className="flex flex-col gap-3">
|
||||
<input type="hidden" name="plazaId" value={plazaID} />
|
||||
<input type="hidden" name="plazaSlug" value={plazaSlug} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`plaza-list-expected-votes-${plazaID}`}>Total da Praça</FieldLabel>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type { PlazaPoliticalTrendViewModel } from '@/utilities/plazaViewModels'
|
|||
|
||||
type PlazaListTrendControlProps = {
|
||||
plazaID: number
|
||||
plazaSlug: string
|
||||
status: PlazaPoliticalTrendViewModel['status']
|
||||
trendNote: string | null
|
||||
formAction: (
|
||||
|
|
@ -26,6 +27,7 @@ type PlazaListTrendControlProps = {
|
|||
|
||||
export const PlazaListTrendControl = ({
|
||||
plazaID,
|
||||
plazaSlug,
|
||||
status,
|
||||
trendNote,
|
||||
formAction,
|
||||
|
|
@ -57,6 +59,7 @@ export const PlazaListTrendControl = ({
|
|||
<PopoverContent align="start" className="w-72">
|
||||
<form action={submitAction} className="flex flex-col gap-3">
|
||||
<input type="hidden" name="plazaId" value={plazaID} />
|
||||
<input type="hidden" name="plazaSlug" value={plazaSlug} />
|
||||
<input type="hidden" name="trendNote" value={trendNote ?? ''} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`plaza-list-trend-${plazaID}`}>Tendência</FieldLabel>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type FormAction = (
|
|||
|
||||
type PlazaStrategyFormProps = {
|
||||
plazaID: number
|
||||
plazaSlug: string
|
||||
strategy: NonNullable<PlazaDetailViewModel['strategy']>
|
||||
strategyFormAction: FormAction
|
||||
expectedVotesFormAction: FormAction
|
||||
|
|
@ -85,6 +86,7 @@ const ListEditor = ({
|
|||
|
||||
export const PlazaStrategyForm = ({
|
||||
plazaID,
|
||||
plazaSlug,
|
||||
strategy,
|
||||
strategyFormAction,
|
||||
expectedVotesFormAction,
|
||||
|
|
@ -102,6 +104,7 @@ export const PlazaStrategyForm = ({
|
|||
<form action={submitStrategy} className="flex flex-col gap-4 rounded-xl border p-4">
|
||||
<h2 className="text-base font-medium">Metas e inteligência</h2>
|
||||
<input type="hidden" name="plazaId" value={plazaID} />
|
||||
<input type="hidden" name="plazaSlug" value={plazaSlug} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor="plaza-priority">Prioridade</FieldLabel>
|
||||
<NativeSelect
|
||||
|
|
@ -192,6 +195,7 @@ export const PlazaStrategyForm = ({
|
|||
</p>
|
||||
</div>
|
||||
<input type="hidden" name="plazaId" value={plazaID} />
|
||||
<input type="hidden" name="plazaSlug" value={plazaSlug} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor="plaza-expected-votes">Total da Praça</FieldLabel>
|
||||
<Input
|
||||
|
|
@ -229,6 +233,7 @@ export const PlazaStrategyForm = ({
|
|||
</p>
|
||||
</div>
|
||||
<input type="hidden" name="plazaId" value={plazaID} />
|
||||
<input type="hidden" name="plazaSlug" value={plazaSlug} />
|
||||
<Field>
|
||||
<FieldLabel htmlFor="plaza-trend-status">Tendência</FieldLabel>
|
||||
<NativeSelect
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ export const optionalFormText = (formData: FormData, field: string): string | un
|
|||
return entry.value.trim() || undefined
|
||||
}
|
||||
|
||||
/** Hidden `plazaSlug` on list/detail plaza forms for scoped revalidation. */
|
||||
export const optionalPlazaSlugFromForm = (formData: FormData): string | undefined =>
|
||||
optionalFormText(formData, 'plazaSlug')
|
||||
|
||||
export const nullableFormText = (formData: FormData, field: string): string | null | undefined => {
|
||||
const entry = formEntry(formData, field)
|
||||
if (!entry.present) return undefined
|
||||
|
|
|
|||
|
|
@ -29,6 +29,15 @@ export const plazaPoliticalTrendSchema = z.object({
|
|||
note: trimmedNullableText(2000),
|
||||
})
|
||||
|
||||
export const parsePoliticalTrendStatusFormValue = (
|
||||
raw: string | undefined,
|
||||
): PoliticalTrendStatusValue | null => {
|
||||
if (!raw) return null
|
||||
return politicalTrendStatuses.includes(raw as PoliticalTrendStatusValue)
|
||||
? (raw as PoliticalTrendStatusValue)
|
||||
: null
|
||||
}
|
||||
|
||||
export const plazaAdvisorsAssignmentSchema = z.object({
|
||||
plaza: positiveRelationshipId,
|
||||
advisors: z
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
aggregatePledgesByPlaza,
|
||||
emptyPlazaPledgeAggregate,
|
||||
resolvePlazaStaffVoteTotal,
|
||||
type PlazaPledgeAggregate,
|
||||
} from '@/utilities/votePledgeData'
|
||||
|
||||
export type { PlazasByIbgeCode, PlazaMapSlugEntry } from '@/utilities/plazaMapNavigation'
|
||||
|
|
@ -72,7 +73,7 @@ export type PlazaMapBundle = {
|
|||
comparison: PlazaMapComparison | null
|
||||
}
|
||||
|
||||
type ScopedPlaza = {
|
||||
export type ScopedPlaza = {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
|
|
@ -82,6 +83,32 @@ type ScopedPlaza = {
|
|||
geography: PlazaElectionGeography
|
||||
}
|
||||
|
||||
type ScopedPlazaDoc = {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
kind: 'municipio' | 'zona'
|
||||
ibgeCode: string
|
||||
expectedVotes?: number | null
|
||||
}
|
||||
|
||||
export const scopePlazasFromDocs = (docs: ReadonlyArray<ScopedPlazaDoc>): ScopedPlaza[] =>
|
||||
docs.flatMap((plaza) => {
|
||||
const entry = getPlazaCatalogEntry(plaza.slug)
|
||||
if (!entry) return []
|
||||
return [
|
||||
{
|
||||
id: plaza.id,
|
||||
slug: plaza.slug,
|
||||
name: plaza.name,
|
||||
kind: plaza.kind,
|
||||
ibgeCode: plaza.ibgeCode,
|
||||
expectedVotes: plaza.expectedVotes ?? null,
|
||||
geography: plazaElectionGeography(entry),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const loadScopedPlazas = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
|
|
@ -98,38 +125,16 @@ const loadScopedPlazas = async (
|
|||
overrideAccess: false,
|
||||
})
|
||||
|
||||
return result.docs.flatMap((plaza) => {
|
||||
const entry = getPlazaCatalogEntry(plaza.slug)
|
||||
if (!entry) return []
|
||||
return [
|
||||
{
|
||||
id: plaza.id,
|
||||
slug: plaza.slug,
|
||||
name: plaza.name,
|
||||
kind: plaza.kind,
|
||||
ibgeCode: plaza.ibgeCode,
|
||||
expectedVotes: plaza.expectedVotes ?? null,
|
||||
geography: plazaElectionGeography(entry),
|
||||
},
|
||||
]
|
||||
})
|
||||
return scopePlazasFromDocs(result.docs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map data for the Praças overview: plazas matching the list URL filters (and
|
||||
* role access) contribute their geography's votes (or 2026 pledge estimates)
|
||||
* to municipality polygons. When `state.compare` is set, TSE years also carry
|
||||
* a red↔white↔blue diff (Solla − other candidate).
|
||||
*/
|
||||
export const loadPlazaMapBundle = async (
|
||||
export const buildPlazaMapBundleFromPlazas = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
searchParams: PlazaListSearchParams,
|
||||
state: PlazaListState,
|
||||
plazas: ScopedPlaza[],
|
||||
pledgeAggregates: Map<number, PlazaPledgeAggregate>,
|
||||
): Promise<PlazaMapBundle | null> => {
|
||||
if (user.role === 'leader') return null
|
||||
|
||||
const state = parsePlazaListParams(searchParams)
|
||||
const plazas = await loadScopedPlazas(payload, user, state)
|
||||
if (plazas.length === 0) return null
|
||||
|
||||
const valuesByYear: Record<string, Record<string, number>> = {}
|
||||
|
|
@ -170,10 +175,6 @@ export const loadPlazaMapBundle = async (
|
|||
}
|
||||
validVotesByYear['2026'] = validVotesByYear['2022'] ?? {}
|
||||
|
||||
const pledgeAggregates = await aggregatePledgesByPlaza(
|
||||
payload,
|
||||
plazas.map((plaza) => plaza.id),
|
||||
)
|
||||
const pledgeValues: Record<string, number> = {}
|
||||
for (const plaza of plazas) {
|
||||
const aggregate = pledgeAggregates.get(plaza.id) ?? emptyPlazaPledgeAggregate
|
||||
|
|
@ -197,12 +198,13 @@ export const loadPlazaMapBundle = async (
|
|||
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
|
||||
|
||||
let comparison: PlazaMapComparison | null = null
|
||||
if (state.compare) {
|
||||
const compareCandidate = state.compare
|
||||
if (compareCandidate) {
|
||||
const candidates = await payload.find({
|
||||
collection: 'electionCandidate',
|
||||
where: {
|
||||
and: [
|
||||
{ candidateNumber: { equals: state.compare } },
|
||||
{ candidateNumber: { equals: compareCandidate } },
|
||||
{ office: { equals: 'deputado_federal' } },
|
||||
{ turn: { equals: '1' } },
|
||||
],
|
||||
|
|
@ -218,28 +220,25 @@ export const loadPlazaMapBundle = async (
|
|||
|
||||
if (candidate) {
|
||||
const diffByYear: Record<string, Record<string, number>> = {}
|
||||
const otherVotesByYear = await Promise.all(
|
||||
HISTORICAL_SERIES_YEARS.map((year) =>
|
||||
loadCandidateVotesByCityZone(payload, user, {
|
||||
await Promise.all(
|
||||
HISTORICAL_SERIES_YEARS.map(async (year) => {
|
||||
const otherVotes = await loadCandidateVotesByCityZone(payload, user, {
|
||||
year,
|
||||
candidateNumber: state.compare!,
|
||||
}),
|
||||
),
|
||||
candidateNumber: compareCandidate,
|
||||
})
|
||||
const sollaVotes = sollaVotesByYear.get(year) ?? new Map<string, number>()
|
||||
const values: Record<string, number> = {}
|
||||
for (const plaza of plazas) {
|
||||
const solla = sumVotesForGeography(sollaVotes, plaza.geography)
|
||||
const other = sumVotesForGeography(otherVotes, plaza.geography)
|
||||
values[plaza.ibgeCode] = (values[plaza.ibgeCode] ?? 0) + (solla - other)
|
||||
}
|
||||
diffByYear[String(year)] = values
|
||||
}),
|
||||
)
|
||||
for (const [index, year] of HISTORICAL_SERIES_YEARS.entries()) {
|
||||
const otherVotes = otherVotesByYear[index]!
|
||||
const sollaVotes = sollaVotesByYear.get(year) ?? new Map<string, number>()
|
||||
const values: Record<string, number> = {}
|
||||
for (const plaza of plazas) {
|
||||
const solla = sumVotesForGeography(sollaVotes, plaza.geography)
|
||||
const other = sumVotesForGeography(otherVotes, plaza.geography)
|
||||
values[plaza.ibgeCode] = (values[plaza.ibgeCode] ?? 0) + (solla - other)
|
||||
}
|
||||
diffByYear[String(year)] = values
|
||||
}
|
||||
|
||||
comparison = {
|
||||
candidateNumber: state.compare,
|
||||
candidateNumber: compareCandidate,
|
||||
candidateName: `${candidate.urnaName}${candidate.party ? ` (${candidate.party})` : ''}`,
|
||||
diffByYear,
|
||||
}
|
||||
|
|
@ -255,3 +254,28 @@ export const loadPlazaMapBundle = async (
|
|||
comparison,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map data for the Praças overview: plazas matching the list URL filters (and
|
||||
* role access) contribute their geography's votes (or 2026 pledge estimates)
|
||||
* to municipality polygons. When `state.compare` is set, TSE years also carry
|
||||
* a red↔white↔blue diff (Solla − other candidate).
|
||||
*/
|
||||
export const loadPlazaMapBundle = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
searchParams: PlazaListSearchParams,
|
||||
): Promise<PlazaMapBundle | null> => {
|
||||
if (user.role === 'leader') return null
|
||||
|
||||
const state = parsePlazaListParams(searchParams)
|
||||
const plazas = await loadScopedPlazas(payload, user, state)
|
||||
if (plazas.length === 0) return null
|
||||
|
||||
const pledgeAggregates = await aggregatePledgesByPlaza(
|
||||
payload,
|
||||
plazas.map((plaza) => plaza.id),
|
||||
)
|
||||
|
||||
return buildPlazaMapBundleFromPlazas(payload, user, state, plazas, pledgeAggregates)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,17 @@ 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, type PlazaListSearchParams } from '@/utilities/plazaUi'
|
||||
import {
|
||||
buildPlazaMapBundleFromPlazas,
|
||||
scopePlazasFromDocs,
|
||||
type PlazaMapBundle,
|
||||
} from '@/utilities/plazaMapData'
|
||||
import {
|
||||
buildPlazaListWhere,
|
||||
parsePlazaListParams,
|
||||
plazaPageSize,
|
||||
type PlazaListSearchParams,
|
||||
} from '@/utilities/plazaUi'
|
||||
import {
|
||||
plazaListSelect,
|
||||
toPlazaDetailViewModel,
|
||||
|
|
@ -15,8 +25,20 @@ import { relationshipId } from '@/utilities/relationship'
|
|||
import {
|
||||
aggregatePledgesByPlaza,
|
||||
rollupPlazaStaffVotes,
|
||||
type PlazaPledgeAggregate,
|
||||
} from '@/utilities/votePledgeData'
|
||||
|
||||
const plazaListFilteredSelect = {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
kind: true,
|
||||
ibgeCode: true,
|
||||
expectedVotes: true,
|
||||
advisors: true,
|
||||
priority: true,
|
||||
} as const
|
||||
|
||||
export class PlazaNotFoundError extends Error {
|
||||
override name = 'PlazaNotFoundError'
|
||||
|
||||
|
|
@ -25,25 +47,40 @@ export class PlazaNotFoundError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export const loadPlazaListPageData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
searchParams: PlazaListSearchParams,
|
||||
): Promise<{
|
||||
export type PlazaListOverviewData = {
|
||||
plazaCount: number
|
||||
staffVoteTotal: number
|
||||
pledgeCount: number
|
||||
missingEstimateCount: number
|
||||
withAdvisorCount: number
|
||||
}
|
||||
|
||||
export type PlazaListPageBundle = {
|
||||
plazas: PlazaListViewModel[]
|
||||
totalDocs: number
|
||||
totalPages: number
|
||||
scopeTotal: number
|
||||
}> => {
|
||||
overview: PlazaListOverviewData | null
|
||||
mapBundle: PlazaMapBundle | null
|
||||
}
|
||||
|
||||
export const loadPlazaListPageBundle = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
searchParams: PlazaListSearchParams,
|
||||
): Promise<PlazaListPageBundle> => {
|
||||
const state = parsePlazaListParams(searchParams)
|
||||
const [result, scope] = await Promise.all([
|
||||
const where = buildPlazaListWhere(state)
|
||||
const isStaff = isCampaignStaff(user)
|
||||
|
||||
const [paginatedResult, scopeCount, filteredResult] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'plaza',
|
||||
depth: 0,
|
||||
limit: plazaPageSize,
|
||||
page: state.page,
|
||||
sort: 'name',
|
||||
where: buildPlazaListWhere(state),
|
||||
where,
|
||||
select: plazaListSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
|
|
@ -54,73 +91,69 @@ export const loadPlazaListPageData = async (
|
|||
user,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
isStaff
|
||||
? payload.find({
|
||||
collection: 'plaza',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
where,
|
||||
select: plazaListFilteredSelect,
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
])
|
||||
|
||||
const pledgeAggregates = isCampaignStaff(user)
|
||||
? await aggregatePledgesByPlaza(
|
||||
payload,
|
||||
result.docs.map((plaza) => plaza.id),
|
||||
let overview: PlazaListOverviewData | null = null
|
||||
let mapBundle: PlazaMapBundle | null = null
|
||||
let pledgeAggregates = new Map<number, PlazaPledgeAggregate>()
|
||||
|
||||
if (filteredResult && filteredResult.docs.length > 0) {
|
||||
pledgeAggregates = await aggregatePledgesByPlaza(
|
||||
payload,
|
||||
filteredResult.docs.map((plaza) => plaza.id),
|
||||
)
|
||||
|
||||
if (isStaff) {
|
||||
const { staffVoteTotal, pledgeCount, missingEstimateCount } = rollupPlazaStaffVotes(
|
||||
filteredResult.docs,
|
||||
pledgeAggregates,
|
||||
)
|
||||
: new Map()
|
||||
overview = {
|
||||
plazaCount: filteredResult.docs.length,
|
||||
staffVoteTotal,
|
||||
pledgeCount,
|
||||
missingEstimateCount,
|
||||
withAdvisorCount: filteredResult.docs.filter((plaza) => (plaza.advisors ?? []).length > 0)
|
||||
.length,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plazas: result.docs.map((plaza) =>
|
||||
toPlazaListViewModel(plaza as Plaza, pledgeAggregates.get(plaza.id)),
|
||||
),
|
||||
totalDocs: result.totalDocs,
|
||||
totalPages: result.totalPages,
|
||||
scopeTotal: scope.totalDocs,
|
||||
const scopedPlazas = scopePlazasFromDocs(filteredResult.docs)
|
||||
if (scopedPlazas.length > 0) {
|
||||
mapBundle = await buildPlazaMapBundleFromPlazas(
|
||||
payload,
|
||||
user,
|
||||
state,
|
||||
scopedPlazas,
|
||||
pledgeAggregates,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type PlazaListOverviewData = {
|
||||
plazaCount: number
|
||||
staffVoteTotal: number
|
||||
declaredVotesTotal: number
|
||||
pledgeCount: number
|
||||
missingEstimateCount: number
|
||||
withAdvisorCount: number
|
||||
highPriorityCount: number
|
||||
}
|
||||
|
||||
/** Staff aggregate over the ENTIRE filtered set (not just the current page). */
|
||||
export const loadPlazaListOverviewData = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
searchParams: PlazaListSearchParams,
|
||||
): Promise<PlazaListOverviewData | null> => {
|
||||
if (!isCampaignStaff(user)) return null
|
||||
|
||||
const state = parsePlazaListParams(searchParams)
|
||||
const result = await payload.find({
|
||||
collection: 'plaza',
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
where: buildPlazaListWhere(state),
|
||||
select: { advisors: true, priority: true, expectedVotes: true },
|
||||
user,
|
||||
overrideAccess: false,
|
||||
})
|
||||
if (result.docs.length === 0) return null
|
||||
|
||||
const plazaIDs = result.docs.map((plaza) => plaza.id)
|
||||
const pledgeAggregates = await aggregatePledgesByPlaza(payload, plazaIDs)
|
||||
const {
|
||||
staffVoteTotal,
|
||||
declaredVotesTotal,
|
||||
pledgeCount,
|
||||
missingEstimateCount,
|
||||
} = rollupPlazaStaffVotes(result.docs, pledgeAggregates)
|
||||
|
||||
return {
|
||||
plazaCount: result.docs.length,
|
||||
staffVoteTotal,
|
||||
declaredVotesTotal,
|
||||
pledgeCount,
|
||||
missingEstimateCount,
|
||||
withAdvisorCount: result.docs.filter((plaza) => (plaza.advisors ?? []).length > 0).length,
|
||||
highPriorityCount: result.docs.filter((plaza) => plaza.priority === 'alta').length,
|
||||
plazas: paginatedResult.docs.map((plaza) =>
|
||||
toPlazaListViewModel(
|
||||
plaza as Plaza,
|
||||
isStaff ? pledgeAggregates.get(plaza.id) : undefined,
|
||||
),
|
||||
),
|
||||
totalDocs: paginatedResult.totalDocs,
|
||||
totalPages: paginatedResult.totalPages,
|
||||
scopeTotal: scopeCount.totalDocs,
|
||||
overview,
|
||||
mapBundle,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
30
src/utilities/plazaRevalidation.ts
Normal file
30
src/utilities/plazaRevalidation.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export type PlazaListRevalidateScope = 'list' | 'detail' | 'both'
|
||||
|
||||
const plazaSlugPathPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
|
||||
type RevalidatePlazaListPathsOptions = {
|
||||
slug?: string
|
||||
scope?: PlazaListRevalidateScope
|
||||
}
|
||||
|
||||
export const revalidatePlazaListPaths = ({
|
||||
slug,
|
||||
scope = 'both',
|
||||
}: RevalidatePlazaListPathsOptions = {}) => {
|
||||
const detailSlug = slug && plazaSlugPathPattern.test(slug) ? slug : undefined
|
||||
|
||||
if (scope === 'list' || scope === 'both') {
|
||||
revalidatePath('/campanha/pracas', 'page')
|
||||
}
|
||||
|
||||
if (scope === 'detail' || scope === 'both') {
|
||||
if (detailSlug) {
|
||||
revalidatePath(`/campanha/pracas/${detailSlug}`, 'page')
|
||||
return
|
||||
}
|
||||
|
||||
revalidatePath('/campanha/pracas/[slug]', 'page')
|
||||
}
|
||||
}
|
||||
107
tests/int/plazaPageData.int.spec.ts
Normal file
107
tests/int/plazaPageData.int.spec.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// @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 { loadPlazaListPageBundle } from '@/utilities/plazaPageData'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('loadPlazaListPageBundle', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
})
|
||||
|
||||
it('returns null overview and map when the filtered plaza set is empty', async () => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
|
||||
const bundle = await loadPlazaListPageBundle(payload, coordinator, { q: 'zzznomatch' })
|
||||
|
||||
expect(bundle.plazas).toHaveLength(0)
|
||||
expect(bundle.overview).toBeNull()
|
||||
expect(bundle.mapBundle).toBeNull()
|
||||
})
|
||||
|
||||
it('rolls up staffVoteTotal from expectedVotes when no pledge overrides apply', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const plaza = await fixtures.getPlaza()
|
||||
fixtures.touchPlaza(plaza.id)
|
||||
|
||||
await payload.update({
|
||||
collection: 'plaza',
|
||||
id: plaza.id,
|
||||
data: { expectedVotes: 1_500 },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const contact = await fixtures.createContact()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
plazas: [plaza.id],
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
await fixtures.createVotePledge({
|
||||
leadership: leadership.id,
|
||||
plaza: plaza.id,
|
||||
declaredVotes: 80,
|
||||
estimatedVotes: 120,
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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 loadPlazaListPageBundle(payload, advisor, { q: administered.name })
|
||||
expect(included.mapBundle).not.toBeNull()
|
||||
expect(included.overview).not.toBeNull()
|
||||
expect(included.plazas.some((row) => row.slug === administered.slug)).toBe(true)
|
||||
|
||||
const excluded =
|
||||
administered.kind === 'zona'
|
||||
? await loadPlazaListPageBundle(payload, advisor, { kind: 'municipio' })
|
||||
: await loadPlazaListPageBundle(payload, advisor, { kind: 'zona' })
|
||||
expect(excluded.mapBundle).toBeNull()
|
||||
expect(excluded.overview).toBeNull()
|
||||
expect(excluded.plazas).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('omits overview and map for leaders', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const plaza = await fixtures.getPlaza()
|
||||
const leader = await fixtures.createCampaignUser('leader')
|
||||
const contact = await fixtures.createContact()
|
||||
await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
plazas: [plaza.id],
|
||||
user: leader.id,
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
|
||||
const bundle = await loadPlazaListPageBundle(payload, leader, { q: plaza.name })
|
||||
|
||||
expect(bundle.overview).toBeNull()
|
||||
expect(bundle.mapBundle).toBeNull()
|
||||
expect(bundle.plazas.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue