diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 0fac6a70..34264323 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -16,7 +16,8 @@ alwaysApply: false - **B2 Mapa Fase 1 (geometrias) entregue (2026-07-18):** TopoJSON estático municípios IBGE (`bahia-municipalities.topo.json`, ~132 KB, object `municipalities`) + TIs por dissolução (`bahia-identity-territories.topo.json`, ~15 KB, object `territories`); tabela `bahiaMunicipalityCodes` + `codeForMunicipality` / `municipalityForCode`; helpers `bahiaGeometries` (`getMunicipalityFeature` / `getTerritoryFeature`); script re-executável `pnpm build:geometries` (cache `data/geometries/`, sem DB); testes int de bijeção/cobertura/teto de bytes. Sem migration, sem UI (Leaflet = B3). Plano as-built: [`docs/plans/mapa-bahia-geometrias.md`](../../../docs/plans/mapa-bahia-geometrias.md). - **B5 registrado (2026-07-18):** débitos de escala/DRY pós-B2 do `/simplify` que não entraram no cleanup (lazy load mun/TI + helper `ensureCachedDownload` para `build:geometries` / `db:seed:tse`) — plano [`docs/plans/escala-dry-pos-b2.md`](../../../docs/plans/escala-dry-pos-b2.md); F1 preferencialmente com B3; F2 cortável. - **C7 registrado (2026-07-18) / entregue (2026-07-19):** débitos de escala/DRY pós-C3 do `/simplify` — plano [`docs/plans/escala-dry-pos-c3.md`](../../../docs/plans/escala-dry-pos-c3.md); composição territorial, shell assíncrono de contato, leituras por aba, short-circuit de hooks. Débitos pós-`/simplify` → **C11** ([`docs/plans/escala-dry-pos-c7.md`](../../../docs/plans/escala-dry-pos-c7.md)). -- **Trilha E registrada (2026-07-19) — mapa de projeção por município:** plano E1–E5 para equiparar e superar as planilhas "Mapa de projeção de votos Solla 2026" (`docs/sheets/`): E1 metas em cenários (`voteGoals` Bom/Regular/Mínimo) + `priority` no núcleo (migration compartilhada com E3), E2 série histórica TSE 2014/2018 + `computeVoteTrend` derivado (sem migration), E3 `dobradinhaNotes`/`nextSteps` staff-only na inteligência, E4 import único `pnpm db:seed:mapa` (sem criar `Contact`/`leadership`), E5 Salvador por bairro registrado como futuro. Somente documentação/plano — nada implementado. Plano: [`docs/plans/mapa-projecao-municipios.md`](../../../docs/plans/mapa-projecao-municipios.md). +- **Trilha E registrada (2026-07-19) — mapa de projeção por município:** plano E1–E5 para equiparar e superar as planilhas "Mapa de projeção de votos Solla 2026" (`docs/sheets/`): E1 metas em cenários (`voteGoals` Bom/Regular/Mínimo) + `priority` no núcleo (migration compartilhada com E3), E2 série histórica TSE 2014/2018 + `computeVoteTrend` derivado (sem migration), E3 `dobradinhaNotes`/`nextSteps` staff-only na inteligência, E4 import único `pnpm db:seed:mapa` (sem criar `Contact`/`leadership`), E5 Salvador por bairro registrado como futuro. Plano: [`docs/plans/mapa-projecao-municipios.md`](../../../docs/plans/mapa-projecao-municipios.md). +- **E2 entregue (2026-07-19):** seed TSE parametrizado (`pnpm db:seed:tse -- --year=2014|2018|2022`; anos históricos só dep. federal T1 BA), `computeVoteTrend` ±10% (`increase|stable|decline|noBaseline`), loader candidate-only multi-ano, série + badge no `NucleusElectoralBaseline`, Alert de tendência no `NucleusInsights`, card "Tendência histórica" no overview da lista. Sem migration. - Fluxos operacionais cobertos: login isolado, CRUD/arquivo de núcleos por slug, designação de coordenador (`geral`), CRM de lideranças, estimativas sugerir/confirmar com versão UUID, feed de atualizações, convites WhatsApp (`wa.me` + hash de uso único), dashboard com agregados só de núcleos ativos. - Pendência operacional (não de código): textos provisórios Onda 0 provisionados por migration ([`docs/plans/onda-0.md`](../../../docs/plans/onda-0.md)); substituição pela assessoria jurídica eleitoral + hold de PII real de lideranças/apoiadores até lá; smoke pós-deploy Vercel. Ver Onda 0 em [`docs/roadmap.md`](../../../docs/roadmap.md). - Em 2026-07-17, a listagem de Núcleos passou a aplicar filtros automaticamente pelo estado da URL, sem botão de confirmação, com comboboxes territoriais estritos compartilhados com o formulário. diff --git a/docs/plans/escala-dry-pos-e2.md b/docs/plans/escala-dry-pos-e2.md new file mode 100644 index 00000000..cd2fe369 --- /dev/null +++ b/docs/plans/escala-dry-pos-e2.md @@ -0,0 +1,105 @@ +# Escala e DRY pós-E2 (série TSE 2014/2018 + tendência) + +Status: registrado no roadmap (fases pendentes) +Atualizado em: 2026-07-19 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E, item E7) +Responsável: — + +## Contexto + +O E2 ([mapa-projecao-municipios.md](mapa-projecao-municipios.md)) entrega seed `pnpm db:seed:tse -- --year=2014|2018|2022` (federal T1 BA nos anos históricos), `computeVoteTrend` ±10% (`increase|stable|decline|noBaseline`), série no card `NucleusElectoralBaseline`, alerta em `NucleusInsights` e card "Tendência histórica" no overview B1 — loader unificado `loadNucleusListElectionOverview` (gap + tendência numa query union) em `nucleusElectoralBaseline.ts`. + +Duas passagens `/simplify` no mesmo branch já limparam o que cabia em cleanup: `HISTORICAL_PRIOR_SERIES_YEARS`, `HISTORICAL_SERIES_YEARS`, `buildUnionGeography`, `aggregateVoteTrend` / `comparableTrendCount`, `formatVoteTrendSeries` / `formatVoteTrendSeriesCompact`, `voteTrendAlertVariant`, merge de labels, `FEDERAL_DEPUTY_OFFICE` no build, remoção de `seriesFromYearTotals` e alias `NucleusListOverviewBaseline2022`. + +Os revisores (performance / reuse / quality) marcaram como **importantes e maiores que simplify** os follow-ups abaixo. Débitos que **já têm dono em outro item** ficam fora deste plano (ver Não escopo). + +1. **Tendência calculada e exibida duas vezes na aba Visão geral do núcleo.** `NucleusActiveTab.tsx` renderiza `NucleusElectoralBaseline` e `NucleusInsights` em sequência; cada um chama `computeVoteTrend(baseline.series)` e ambos mostram a série 2014→2018→2022 (badge + mensagem no card; alerta + mensagem + série no insights). Duplicação de trabalho no cliente e de informação para o usuário. +2. **Sem testes de integração do loader combinado.** `loadNucleusListElectionOverview` substitui loaders separados de gap e tendência; só há cobertura unitária de `computeVoteTrend` / `aggregateVoteTrend` e unit do overview VM — não há int que exercite a união geográfica, o caso "só tendência sem estimativas comparáveis" e o agregado gap+trend num único fetch. +3. **Tipos de distribuição duplicados.** `NucleusTrendOverviewAggregate` em `nucleusElectoralBaseline.ts` é `Record` enquanto `electionInsights.ts` já expõe `VoteTrendDistribution` + `emptyVoteTrendDistribution` — mesmo shape, dois nomes. +4. **`VoteTrendResult.ratio` exposto na API pública** mas usado sobretudo em asserts de teste; o produto só consome `status` + `message` na UI. + +**Explicitamente fora (revisores pediram skip no simplify ou já têm item):** ranking federal completo no detalhe e filtro por `cityCode` → **A7**; query duplicada lista+overview e re-resolve de geografia no baseline do overview → **E6**; DRY de `formatElectionNumber` nos cards não-tendência do overview → **E6 F3**; remover a query TSE do overview quando não há estimativas comparáveis (o card de tendência exige distribuição mesmo sem gap — comportamento intencional de produto). + +## Objetivos + +- Abrir a aba Visão geral de um núcleo calcula a tendência **uma vez** e não repete a série histórica em dois blocos adjacentes. +- `loadNucleusListElectionOverview` tem pelo menos um teste int com fixture TSE que cobre gap agregado + distribuição de tendência no mesmo path. +- Tipos de distribuição de tendência têm uma única fonte (`VoteTrendDistribution`); loaders e VMs importam de `electionInsights.ts`. +- Guardrails: sem migration, sem Consent (dado público TSE). Access continua `overrideAccess: false`. Identificadores em inglês; strings visíveis em pt-BR. + +## Decisões travadas + +- **Um item E7, três fases ordenadas.** Mesmo racional de A7/C8/E6: um ID no roadmap, PRs por fase. Ordem: UX do detalhe (duplicação visível) → testes int do loader → higiene de tipos/API. +- **Dependência dura de E2 mergeado.** Só faz sentido com seed multi-ano, `computeVoteTrend` e `loadNucleusListElectionOverview` já no código. +- **Fase 1 não remove o insight de tendência nem o badge no baseline** — apenas unifica o cálculo e evita repetir a série literal; o alerta pode mostrar só `trend.message` se o card acima já exibir `formatVoteTrendSeriesCompact`. +- **Cortável se poucos núcleos e time de campo não reclamar da duplicação visual** — Fase 2 (int) é a mais valiosa antes de E4 import em massa amplificar o conjunto filtrado. +- **i18n e naming** (AGENTS.md): `VoteTrendDistribution` canônico; `NucleusTrendOverviewAggregate` vira alias deprecado ou some na Fase 3. + +## Questões em aberto + +- **Fase 1: tendência só no Insights ou só no Baseline?** **Recomendação:** manter badge + série compacta no `NucleusElectoralBaseline` (paridade com design `Baseline-Eleitoral-2022`); `NucleusInsights` recebe `trend: VoteTrendResult` por prop e mostra só o alerta com `trend.message` (sem repetir a série). `computeVoteTrend` roda uma vez em `NucleusActiveTab`. +- **Fase 2: fixture int separada ou estender `tseFixtures.ts`?** **Recomendação:** estender `tests/helpers/tseFixtures.ts` com série 2014/2018/2022 mínima para dois núcleos sobrepostos na geografia union; reutilizar padrão de `electionResultsImport.int.spec.ts`. +- **Fase 3: remover `ratio` de `VoteTrendResult`?** **Recomendação:** sim, se nenhum caller de produção ler — ajustar testes para assertar só `status`/`message` ou testar `ratio` via função interna de par se ainda for útil para regressão. + +## Abordagem proposta + +```mermaid +flowchart TD + E2["E2 Série + tendência ✓"] --> F1 + F1["Fase 1 — Trend VM único no detalhe
(NucleusActiveTab → filhos)"] + F1 --> F2["Fase 2 — Int loadNucleusListElectionOverview"] + F1 --> F3["Fase 3 — VoteTrendDistribution único + API ratio"] + F2 -.confiança antes.-> E4["E4 Import planilha"] + A7["A7 Loader baseline"] -.mesmo módulo.-> F2 + E6["E6 Lista/overview"] -.geo cache.-> F2 +``` + +### Fase 1 — Tendência única na aba Visão geral + +- `src/components/campaign/NucleusActiveTab.tsx`: `const trend = baseline ? computeVoteTrend(baseline.series) : null`; passar `trend` opcional para `NucleusElectoralBaseline` e `NucleusInsights`. +- `NucleusElectoralBaseline`: aceitar `trend?: VoteTrendResult | null` — se presente, não chamar `computeVoteTrend` internamente. +- `NucleusInsights`: aceitar `trend` por prop; remover segunda cópia da série no `AlertDescription` (manter só mensagem classificada). +- Testes: ajustar `nucleusElectoralBaselineUi.unit.spec.ts` se asserts dependerem do texto duplicado da série. + +### Fase 2 — Testes int do loader combinado + +- Novo ou estendido `tests/int/nucleusListElectionOverview.int.spec.ts` (ou seção em spec existente de election baseline). +- Cenários mínimos: (a) dois núcleos com geografia + estimativas → `gapTotal` e contagem `above`/`below`; (b) núcleos com geografia mas sem estimativa confirmada → `gapTotal: null` e `trend` populado; (c) núcleo sem território resolvível → `baseline2022: null`, `trend: null`. +- Usar `getPayload` + DB de teste (`teqo_test`); seed fixture TSE via helper, não Neon. + +### Fase 3 — Tipos e API + +- Substituir `NucleusTrendOverviewAggregate` por import de `VoteTrendDistribution` nos VMs (`nucleusListOverviewViewModels.ts`, `nucleusElectoralBaseline.ts` exports). +- Avaliar remoção de `VoteTrendResult.ratio` ou torná-lo `@internal` documentado; atualizar `electionInsights.unit.spec.ts`. + +**Migration:** nenhuma nas três fases. + +## Dependências + +- **Dura:** E2 Série TSE 2014/2018 + tendência — implementado no branch (aguardando merge). +- **Suave:** E4 import planilha — amplifica valor da Fase 2; A7 F1 compartilha `nucleusElectoralBaseline.ts` (não misturar PRs sem necessidade); E6 F2 cache de geografia reduz custo do mesmo path do overview. +- Reusa: `electionInsights.ts`, `nucleusElectoralBaseline.ts`, `nucleusListOverviewPageData.ts`, `tests/helpers/tseFixtures.ts`, padrão int de `electionResultsImport.int.spec.ts`. + +## Não escopo + +- Agregar ranking federal no detalhe → [escala-dry-pos-a4.md](escala-dry-pos-a4.md) (A7 F1). +- Filtro geográfico por `cityCode` TSE → A7 F2. +- Query duplicada `/campanha/nucleos` lista + overview → [escala-dry-pos-e1.md](escala-dry-pos-e1.md) (E6 F1). +- Re-resolve de geografia no overview baseline → E6 F2. +- `formatElectionNumber` nos cards de estimativa/cobertura/metas do overview → E6 F3. +- Seed 2014 se formato TSE incompatível (spike já validou 2018; 2014 é risco separado no runbook do seed). +- Persistir tendência no banco (permanece derivada em leitura). + +## Referências + +- `docs/roadmap.md` (Trilha E, E7; E2 implementado) +- `docs/plans/mapa-projecao-municipios.md` — plano pai E2 +- `docs/plans/escala-dry-pos-a4.md` / `escala-dry-pos-e1.md` — débitos adjacentes (A7 / E6) +- `src/components/campaign/NucleusActiveTab.tsx` — render duplo baseline + insights +- `src/components/campaign/NucleusElectoralBaseline.tsx` / `NucleusInsights.tsx` +- `src/lib/electionInsights.ts` — `computeVoteTrend`, `VoteTrendDistribution`, `aggregateVoteTrend` +- `src/utilities/nucleusElectoralBaseline.ts` — `loadNucleusListElectionOverview`, `getNucleusElectoralBaseline` +- `src/utilities/nucleusListOverviewPageData.ts` +- `tests/unit/electionInsights.unit.spec.ts` / `tests/unit/nucleusElectoralBaselineUi.unit.spec.ts` +- `tests/helpers/tseFixtures.ts` / `tests/int/electionResultsImport.int.spec.ts` +- AGENTS.md — naming, `overrideAccess: false`, seed guards diff --git a/docs/plans/mapa-projecao-municipios.md b/docs/plans/mapa-projecao-municipios.md index 41dc4b75..44d26303 100644 --- a/docs/plans/mapa-projecao-municipios.md +++ b/docs/plans/mapa-projecao-municipios.md @@ -1,6 +1,6 @@ # Mapa de projeção de votos por município (equiparar e superar as planilhas 2026) -Status: rascunho +Status: rascunho (E2 implementado 2026-07-19) Atualizado em: 2026-07-19 (revisão access E1/E3 — metas/prioridade/E3 sem `canSetDerivedNucleusField`; escrita via `canUpdateElectoralNucleus` + selects/VMs) Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha E — itens E1–E5, "Ciclo 3") Responsável: — @@ -30,7 +30,7 @@ A vertical `/campanha` já cobre ou supera parte disso: baseline TSE 2022 por ge ## Questões em aberto -- **Banda de estabilidade da tendência?** Queda/Mantém/Aumento precisa de limiar. **Recomendação:** ±10% entre eleições consecutivas, constante versionada em `electionInsights.ts`; validar com produto. +- **Banda de estabilidade da tendência?** **Resolvido (2026-07-19):** ±10% (`VOTE_TREND_STABLE_BAND = 0.1` em `electionInsights.ts`); par preferido 2018→2022. - **E4 cria núcleo para todo município com dado estratégico (≈193) ou só para os ~50 prioritários?** **Recomendação:** default = municípios com qualquer preenchimento estratégico (metas, dobradinha, encaminhamento), flag `--priority-only` para restringir. Definir com produto antes de rodar em produção. - **Meta estadual (71.000) fica onde?** **Recomendação:** derivada — soma das metas dos núcleos no dashboard; sem campo global novo. Se produto quiser meta editável independente da soma, vira campo em global de campanha (fase posterior). @@ -62,10 +62,11 @@ flowchart LR ### E2 — Série histórica TSE 2014/2018 + tendência automática -- **Seed:** estender `scripts/seed-tse-results.mjs` para parametrizar o ano (2014/2018; URLs `*_YYYY.zip` das mesmas famílias TSE, SHA-256 no header). Escopo v1: **só `deputado_federal`, turno 1, BA** (limita volume; a tendência só precisa disso). Verificar na implementação se o TSE republicou 2014 no formato CSV com header — o parser (`src/lib/electionResultsParse.ts`) assume colunas nomeadas. Número do candidato configurável por ano (1313 nos três pleitos). Idempotência por `(year, office, turn)` e collections A3 com `year` na unique key já existem — **sem migration**. -- **Insight:** `computeVoteTrend(votes2014, votes2018, votes2022)` → `queda|mantem|aumento|semBaseline` em `src/lib/electionInsights.ts`, limiares como constantes versionadas. -- **UI:** série 2014→2018→2022 no card `NucleusElectoralBaseline` (`src/components/campaign/NucleusElectoralBaseline.tsx`); badge de tendência no detalhe + linha no `NucleusInsights` (mesma família de Alert do Gap vs 2022); no overview da lista, distribuição Queda/Mantém/Aumento do conjunto filtrado (espelha a aba RESUMO da planilha). -- **Custo de I/O:** estender `getNucleusElectoralBaseline` (`src/utilities/nucleusElectoralBaseline.ts`) para aceitar ano multiplica as leituras já sinalizadas em [A7](escala-dry-pos-a4.md) — preferir levar A7 F1 (agregar federal no detalhe) junto ou antes. +- **Seed:** `scripts/seed-tse-results.mjs` parametrizado por `--year=2014|2018|2022` (URLs `*_YYYY.zip` das mesmas famílias TSE; SHA-256 logado, pins opcionais em `EXPECTED_SHA256`). Escopo v1 histórico: **só `deputado_federal`, turno 1, BA**. Parser em `electionResultsCsv.ts` + build em `electionResultsBuild.ts` (`offices` opcional via `federalOnlyElectionOffices()`). Número do candidato: `CANDIDATE_NUMBER_BY_YEAR` (1313 nos três pleitos). Idempotência por `(year, office, turn)` — **sem migration**. +- **Insight:** `computeVoteTrend` + `VOTE_TREND_STABLE_BAND = 0.1` (±10%) em `src/lib/electionInsights.ts` → `queda|mantem|aumento|semBaseline`; par preferido 2018→2022. +- **Loader:** `loadCandidateSeriesByGeography` (query candidate-only multi-ano, espelha overview Gap) em `nucleusElectoralBaseline.ts`; `loadNucleusTrendOverview` para distribuição na lista. +- **UI:** série 2014→2018→2022 + badge no `NucleusElectoralBaseline`; Alert `vote-trend` no `NucleusInsights`; card "Tendência histórica" no `NucleusListOverview`. +- **Custo de I/O:** série usa query estreita (não multiplica o path caro do ranking federal — A7 F1 permanece backlog separado). Passagem `/simplify` no branch; débitos maiores → [escala-dry-pos-e2.md](escala-dry-pos-e2.md) (E7). ### E3 — Inteligência estratégica manual: dobradinhas + encaminhamentos @@ -107,8 +108,9 @@ flowchart LR - `docs/sheets/Mapa_projecao_votos_Solla_2026.xlsx` e `docs/sheets/Mapa projeção de votos Solla 2026.xlsx` — planilhas-fonte (estrutura, abas RESUMO/PRIORITÁRIAS/SALVADOR) - `src/collections/ElectoralNucleus.ts` — campos, hooks e access do núcleo (E1/E3) - `src/lib/electionInsights.ts` + `src/utilities/nucleusElectoralBaseline.ts` — padrão de insight derivado e agregação do baseline (E2) -- `scripts/seed-tse-results.mjs` + `src/lib/electionResultsParse.ts` — seed TSE a parametrizar por ano (E2) +- `scripts/seed-tse-results.mjs` + `src/lib/electionResultsBuild.ts` + `src/lib/electionResultsCsv.ts` — seed TSE parametrizado por ano (E2) - `src/components/campaign/NucleusElectoralBaseline.tsx`, `NucleusListOverview.tsx`, `NucleusIntelligenceDialog.tsx` — superfícies de UI a estender - `src/lib/bahiaMunicipalityCodes.ts` (`canonicalizeMunicipalityName`) — reconciliação de nomes no import (E4) - [escala-dry-pos-a4.md](escala-dry-pos-a4.md) — A7 F1, custo de I/O do loader do baseline +- [escala-dry-pos-e2.md](escala-dry-pos-e2.md) — E7, débitos pós-`/simplify` do E2 - AGENTS.md — Campaign auth, pessoa = `Contact` + junção, `overrideAccess: false`, migrations com `push: false`, naming diff --git a/docs/roadmap.md b/docs/roadmap.md index 1287e526..e4daebbd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md)) +Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md)) Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos. @@ -89,7 +89,8 @@ Objetivo: equiparar e superar as planilhas "Mapa de projeção de votos Solla 20 - **E1 Metas em cenários + prioridade** — grupo `voteGoals` (`good`/`regular`/`minimum`) + `priority` (`alta|normal`) em `electoralNucleus` (migration compartilhada com E3); card "Metas 2026" no detalhe, filtro na lista, somas no overview B1 e no dashboard (meta estadual derivada). Passagem `/simplify` no branch; débitos maiores → E6. - **E6 Escala e DRY pós-E1+E3 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: query duplicada lista+overview, re-resolve de geografia no baseline do overview, aggregate SQL de metas/prioridade (padrão C6), DRY `formatElectionNumber`/`campaignPercentage`/`voteGoalScenarioLabels`, tipos do dashboard por papel. [Plano](plans/escala-dry-pos-e1.md). -- **E2 Série histórica TSE 2014/2018 + tendência** — seed `db:seed:tse` parametrizado por ano (só dep. federal, turno 1, BA); `computeVoteTrend` → `queda|mantem|aumento|semBaseline` derivado em leitura (sem migration); série + badge no baseline card e distribuição no overview. +- **E2 Série histórica TSE 2014/2018 + tendência** — seed `pnpm db:seed:tse -- --year=2014|2018` (federal T1 BA only); `computeVoteTrend` ±10% (`increase|stable|decline|noBaseline`); série + badge no baseline card, insight e card de distribuição no overview; query candidate-only multi-ano (sem migration). **Implementado 2026-07-19.** Passagem `/simplify` no branch; débitos maiores → E7. +- **E7 Escala e DRY pós-E2 registrado (2026-07-19)** — débitos do `/simplify` que não entraram no cleanup: trend VM único no detalhe (sem série duplicada baseline+insights), testes int de `loadNucleusListElectionOverview`, tipo único `VoteTrendDistribution`. [Plano](plans/escala-dry-pos-e2.md). - **E3 Dobradinhas + encaminhamentos manuais** — `dobradinhaNotes` e `nextSteps` staff-only na inteligência do núcleo (excluídos dos view models de `lideranca`); versão manual imediata que A6/C4 complementam depois. - **E4 Import único da planilha** — `pnpm db:seed:mapa` idempotente por slug, município canônico via `canonicalizeMunicipalityName`; lideranças/assessores da planilha entram como **texto staff-only, nunca `Contact`/`leadership`** (sem telefone/consent). - **E5 Salvador por bairro** — registrado como futuro (exigiria `votacao_secao` + DE-PARA local↔bairro versionado); a visão por zona já é coberta pelo baseline município×zona. @@ -119,6 +120,7 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig | VR+ Escala e DRY pós-visitados | — (sem design novo; reusa dashboard + padrões de lista de núcleos) | [escala-dry-pos-visitados-recentemente.md](plans/escala-dry-pos-visitados-recentemente.md) | | B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) | | E6 Escala e DRY pós-E1+E3 | — (sem design novo; reusa Metas 2026 + overview B1) | [escala-dry-pos-e1.md](plans/escala-dry-pos-e1.md) | +| E7 Escala e DRY pós-E2 | — (sem design novo; reusa Baseline + Insights + overview tendência) | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) | | D2 Notificações (sino) | `Notificacoes-PWA` (central de notificações) | [notifications.md](plans/notifications.md) | | D1 PWA `/campanha` | `Notificacoes-PWA` (banner "Instale o app da campanha") | [pwa-campanha.md](plans/pwa-campanha.md) | | C5 Operação dia D / GOTV | `Dia-D-GOTV` | sem plano ainda — o design é a primeira materialização do item (check-off de apoiadores por seção/zona, progresso "62% da base", filtro "Ainda não confirmados", estado "Sincronizado" offline-tolerante) | @@ -176,7 +178,8 @@ flowchart TD subgraph TrilhaE["Trilha E — mapa de projeção por município"] E1["E1 Metas em cenários + prioridade
(migration c/ E3)"] - E2["E2 Série TSE 2014/2018
+ tendência"] + E2["E2 Série TSE 2014/2018
+ tendência ✓"] + E7["E7 Escala e DRY pós-E2
(trend VM / int loader / tipos)"] E3["E3 Dobradinhas + encaminhamentos
manuais (staff-only)"] E4["E4 Import único da planilha
(pnpm db:seed:mapa)"] E5["E5 Salvador por bairro
(futuro; validar valor)"] @@ -226,10 +229,14 @@ flowchart TD E1 -.metas no coroplético.-> B3 E2 -.classe por cor.-> B3 E2 -.dataset votacao_secao.-> E5 + E2 --> E7 + E7 -.int loader antes.-> E4 A7 -.mesmo loader do baseline.-> E2 + A7 -.mesmo módulo.-> E7 + E6 -.geo cache.-> E7 ``` -Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓) e fill-ins (listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), **E6 (após merge E1+E3 em `main`)**, B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import; suave: E6 F1 reduz custo da lista antes do import em massa). +Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓) e fill-ins (listas globais, reset de senha, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), E2 (após A4 ✓), **E7 (após E2 merge; Fase 2 int preferível antes de E4)**, **E6 (após merge E1+E3 em `main`)**, B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import; suave: E6 F1 e E7 F2 reduzem custo/risco do overview antes do import em massa). ### Sequência de execução por janela do calendário @@ -259,7 +266,8 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0 | 15 | C11 Escala e DRY pós-C7 (feed O(n) condicional, loaders do detalhe, selects por aba, hot paths mutação/form, utilities de busca) _(cortável se a agenda permanecer pequena; Fase 1 migration só com volume medido)_ | [detalhes](plans/escala-dry-pos-c7.md) | C7 (merge) | A4, C3, C10, E1+E3, E2 | | 16 | E1 Metas em cenários + prioridade + E3 dobradinhas/encaminhamentos manuais (migration compartilhada em `electoralNucleus`; `/simplify` → E6) | [detalhes](plans/mapa-projecao-municipios.md) | — | tudo da janela | | 17 | E6 Escala e DRY pós-E1+E3 (aggregate SQL metas/prioridade, geografia baseline, DRY formatação) _(cortável se poucos núcleos ativos)_ | [detalhes](plans/escala-dry-pos-e1.md) | E1+E3 merge em `main` | E2, C10, A7 | -| 18 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, E6, C7, C8, C9, C10, C11 | +| 18 | E2 Série histórica TSE 2014/2018 + tendência automática (seed por ano; sem migration) **(implementado 2026-07-19; `/simplify` → E7)** | [detalhes](plans/mapa-projecao-municipios.md) | A4 ✓ (suave: A7 F1 — mesmo loader) | E1+E3, E6, E7, C7, C8, C9, C10, C11 | +| 19 | E7 Escala e DRY pós-E2 (trend VM único no detalhe, int `loadNucleusListElectionOverview`, tipo `VoteTrendDistribution`) _(cortável se poucos núcleos; Fase 2 int preferível antes de E4)_ | [detalhes](plans/escala-dry-pos-e2.md) | E2 (merge) | E6, A7, E4, C10 | **Janela 3 — 16/08 → set (campanha de rua): inteligência ampliada, visualização e engajamento.** @@ -292,7 +300,7 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0 - **Reset de senha self-service + foto de perfil** (UX adiada do ciclo 1). _(plano MVP)_ - **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_ -**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa. +**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **E7 F1 e F3** (dedup visual/API — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa. ### Itens consolidados/removidos nesta revisão (2026-07-17) diff --git a/scripts/seed-tse-results.mjs b/scripts/seed-tse-results.mjs index c00b959d..76d9b4d6 100644 --- a/scripts/seed-tse-results.mjs +++ b/scripts/seed-tse-results.mjs @@ -1,27 +1,31 @@ /** * Seeds electionTally / electionCandidateVote / electionCandidate from TSE - * open data for the 2022 general election (Bahia scope). + * open data (Bahia scope). Default year: 2022 (full ticket). Historical years + * 2014/2018 import only deputado_federal turno 1 (E2 series). * * Provenance (do not strip): - * - Portal: https://dadosabertos.tse.jus.br/dataset/resultados-2022 - * - Portal: https://dadosabertos.tse.jus.br/dataset/candidatos-2022 - * - votacao_candidato_munzona_2022.zip - * https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2022.zip - * - detalhe_votacao_munzona_2022.zip - * https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip - * - consulta_cand_2022.zip - * https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_2022.zip + * - Portal 2022: https://dadosabertos.tse.jus.br/dataset/resultados-2022 + * - Portal 2018: https://dadosabertos.tse.jus.br/dataset/resultados-2018 + * - Portal 2014: https://dadosabertos.tse.jus.br/dataset/resultados-2014 + * - Portal candidatos: https://dadosabertos.tse.jus.br/dataset/candidatos-{year} + * - CDN zips: https://cdn.tse.jus.br/estatistica/sead/odsele/{family}/{family}_{year}.zip * - License: Creative Commons Atribuição (dados abertos TSE) * - electionCandidateVote.votes ← QT_VOTOS_NOMINAIS (votos apurados; validade no tally) * - v1: only voteType=nominal (no votacao_partido_munzona / legenda rows) * + * Pinned SHA-256 (update after verifying a fresh download): + * - 2014/2018: log-only until first verified import in CI/local + * - 2022: log-only (existing deployments may have cached zips) + * * Safety: refuses non-local DATABASE_URL unless ALLOW_REMOTE_DB=true. * Idempotent: replace-by-scope (year, office, turn) via drizzle transaction. * * Usage: * pnpm db:seed:tse + * pnpm db:seed:tse -- --year=2018 + * pnpm db:seed:tse -- --year=2014 * ALLOW_REMOTE_DB=true pnpm db:seed:tse - * TSE_CACHE_DIR=./data/tse pnpm db:seed:tse # reuse downloaded zips + * TSE_CACHE_DIR=./data/tse pnpm db:seed:tse */ import { createHash } from 'node:crypto' @@ -37,27 +41,50 @@ loadEnv({ path: '.env' }) const config = (await import('../src/payload.config.ts')).default const { parseTseCsvBuffer } = await import('../src/lib/electionResultsCsv.ts') -const { buildElectionResultsFromCsvRows } = await import('../src/lib/electionResultsBuild.ts') +const { buildElectionResultsFromCsvRows, FEDERAL_ONLY_OFFICES } = await import( + '../src/lib/electionResultsBuild.ts' +) const { downloadToBuffer, readZipEntry } = await import('../src/lib/electionResultsZip.ts') const { buildImportBundles, importElectionBundles } = await import( '../src/utilities/electionResultsImport.ts' ) -const SOURCES = [ +const SUPPORTED_YEARS = [2014, 2018, 2022] + +/** Optional integrity pins — null = warn on mismatch only after download. */ +const EXPECTED_SHA256 = { + 2014: null, + 2018: null, + 2022: null, +} + +const sourcesForYear = (year) => [ { - key: 'votacao_candidato_munzona_2022', - url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2022.zip', + key: `votacao_candidato_munzona_${year}`, + url: `https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_${year}.zip`, + expectedSha256: EXPECTED_SHA256[year], }, { - key: 'detalhe_votacao_munzona_2022', - url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_2022.zip', + key: `detalhe_votacao_munzona_${year}`, + url: `https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_munzona/detalhe_votacao_munzona_${year}.zip`, + expectedSha256: EXPECTED_SHA256[year], }, { - key: 'consulta_cand_2022', - url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_2022.zip', + key: `consulta_cand_${year}`, + url: `https://cdn.tse.jus.br/estatistica/sead/odsele/consulta_cand/consulta_cand_${year}.zip`, + expectedSha256: EXPECTED_SHA256[year], }, ] +const csvNamesForYear = (year) => ({ + voteBa: `votacao_candidato_munzona_${year}_BA.csv`, + voteBr: `votacao_candidato_munzona_${year}_BR.csv`, + detalheBa: `detalhe_votacao_munzona_${year}_BA.csv`, + detalheBr: `detalhe_votacao_munzona_${year}_BR.csv`, + candBa: `consulta_cand_${year}_BA.csv`, + candBr: `consulta_cand_${year}_BR.csv`, +}) + const die = (message) => { console.error(`\n[seed:tse] ${message}\n`) process.exit(1) @@ -65,25 +92,41 @@ const die = (message) => { const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex') +const parseYearArg = () => { + const explicit = process.argv.find((arg) => arg.startsWith('--year=')) + if (!explicit) return 2022 + const year = Number(explicit.slice('--year='.length)) + if (!SUPPORTED_YEARS.includes(year)) { + die(`Unsupported --year=${year}. Supported: ${SUPPORTED_YEARS.join(', ')}`) + } + return year +} + const cacheDir = () => process.env.TSE_CACHE_DIR || join(process.cwd(), 'data', 'tse') -const ensureCachedZip = async ({ key, url }) => { +const ensureCachedZip = async ({ key, url, expectedSha256 }) => { const dir = cacheDir() await mkdir(dir, { recursive: true }) const path = join(dir, `${key}.zip`) + let buffer try { await access(path) console.log(`[seed:tse] cache hit ${path}`) - const buffer = await readFile(path) - return { key, url, buffer, hash: sha256(buffer) } + buffer = await readFile(path) } catch (error) { if (error?.code !== 'ENOENT') throw error + console.log(`[seed:tse] downloading ${url}`) + buffer = await downloadToBuffer(url) + await writeFile(path, buffer) + console.log(`[seed:tse] saved ${path} (${buffer.length} bytes)`) } - console.log(`[seed:tse] downloading ${url}`) - const buffer = await downloadToBuffer(url) + const hash = sha256(buffer) - await writeFile(path, buffer) - console.log(`[seed:tse] saved ${path} (${buffer.length} bytes, sha256=${hash})`) + if (expectedSha256 && hash !== expectedSha256) { + die( + `SHA-256 mismatch for ${key}\n expected=${expectedSha256}\n actual=${hash}\n Delete ${path} and retry.`, + ) + } return { key, url, buffer, hash } } @@ -95,6 +138,9 @@ const loadCsvFromZip = async (zipBuffer, fileName) => { } const main = async () => { + const year = parseYearArg() + const historicalOnly = year === 2014 || year === 2018 + assertLocalDatabase( 'seed:tse', 'This seed WRITES election reference data and is meant for the local Postgres.\n' + @@ -102,33 +148,33 @@ const main = async () => { ' 2. set DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo in .env.local', ) - const [votacaoZip, detalheZip, candZip] = await Promise.all(SOURCES.map(ensureCachedZip)) + const sources = sourcesForYear(year) + const [votacaoZip, detalheZip, candZip] = await Promise.all(sources.map(ensureCachedZip)) + console.log(`[seed:tse] year=${year} scope=${historicalOnly ? 'deputado_federal T1 BA' : 'full ticket BA'}`) console.log('[seed:tse] provenance:') for (const source of [votacaoZip, detalheZip, candZip]) { console.log(` - ${source.url}`) console.log(` sha256=${source.hash}`) } + const csv = csvNamesForYear(year) + // Sequential: BA vote CSV alone is hundreds of MB; parallel parse spikes peak RAM. - const voteBa = await loadCsvFromZip(votacaoZip.buffer, 'votacao_candidato_munzona_2022_BA.csv') - const voteBr = await loadCsvFromZip(votacaoZip.buffer, 'votacao_candidato_munzona_2022_BR.csv') - const detalheBa = await loadCsvFromZip( - detalheZip.buffer, - 'detalhe_votacao_munzona_2022_BA.csv', - ) - const detalheBr = await loadCsvFromZip( - detalheZip.buffer, - 'detalhe_votacao_munzona_2022_BR.csv', - ) - const candBa = await loadCsvFromZip(candZip.buffer, 'consulta_cand_2022_BA.csv') - const candBr = await loadCsvFromZip(candZip.buffer, 'consulta_cand_2022_BR.csv') + const voteBa = await loadCsvFromZip(votacaoZip.buffer, csv.voteBa) + const voteBr = historicalOnly ? [] : await loadCsvFromZip(votacaoZip.buffer, csv.voteBr) + const detalheBa = await loadCsvFromZip(detalheZip.buffer, csv.detalheBa) + const detalheBr = historicalOnly ? [] : await loadCsvFromZip(detalheZip.buffer, csv.detalheBr) + const candBa = await loadCsvFromZip(candZip.buffer, csv.candBa) + const candBr = historicalOnly ? [] : await loadCsvFromZip(candZip.buffer, csv.candBr) const built = buildElectionResultsFromCsvRows({ voteRows: [...voteBa, ...voteBr], - detalheRows: [...detalheBa, ...detalheBr], + tallyRows: [...detalheBa, ...detalheBr], candBaRows: candBa, candBrRows: candBr, + year, + ...(historicalOnly ? { offices: FEDERAL_ONLY_OFFICES } : {}), }) if (built.unknownMunicipalities.length > 0) { @@ -162,7 +208,7 @@ const main = async () => { } console.log( - `\n[seed:tse] done. inserted votes=${votes} tallies=${tallies} candidates=${candidates}`, + `\n[seed:tse] done (year=${year}). inserted votes=${votes} tallies=${tallies} candidates=${candidates}`, ) process.exit(0) } diff --git a/src/components/campaign/NucleusElectoralBaseline.tsx b/src/components/campaign/NucleusElectoralBaseline.tsx index 95d9df14..a5255014 100644 --- a/src/components/campaign/NucleusElectoralBaseline.tsx +++ b/src/components/campaign/NucleusElectoralBaseline.tsx @@ -1,7 +1,15 @@ -import { ChartColumnIcon, TrophyIcon } from 'lucide-react' +import { ChartColumnIcon, TrendingDownIcon, TrendingUpIcon, TrophyIcon } from 'lucide-react' +import { Badge } from '@/components/ui/Badge' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { formatElectionNumber, NO_ELECTION_BASELINE_MESSAGE } from '@/lib/electionInsights' +import { + computeVoteTrend, + formatElectionNumber, + formatVoteTrendSeriesCompact, + NO_ELECTION_BASELINE_MESSAGE, + voteTrendBadgeVariant, + voteTrendStatusLabel, +} from '@/lib/electionInsights' import { BASELINE_TICKET_2022 } from '@/lib/electionResults' import type { NucleusElectoralBaselineViewModel } from '@/utilities/nucleusViewModels' @@ -41,6 +49,7 @@ export const NucleusElectoralBaseline = ({ 1, ) const candidateBar = barPercent(baseline.candidate.votes, scale) + const trend = computeVoteTrend(baseline.series) return ( @@ -50,7 +59,19 @@ export const NucleusElectoralBaseline = ({