Merge branch 'cursor/4c2433dd'
This commit is contained in:
commit
4b7f2f595d
30 changed files with 1088 additions and 211 deletions
|
|
@ -9,6 +9,7 @@ alwaysApply: false
|
|||
|
||||
- **E16 entregue em código (2026-07-25):** Dossiê do município (pré-agenda) — aba `?tab=dossie` no detalhe (`MunicipalityDossier.tsx`, server component em Suspense) compondo os loaders existentes via `municipalityDossierData.ts` (baseline TSE + rank A11, conta da cadeira E8, pledges, rede de lideranças com frescor `updatedAt`, agenda `actionPlan` `overrideAccess: false`, sinais C12, perfil IBGE A8 com caveat para zonas — reentrada do artefato órfão); caps 8 lideranças / 5 sinais / 3+2 planos com "ver tudo" por seção; novo campo staff-only `municipality.budgetNotes` ("Emendas aportadas", G11 manual-first) via migration `20260725_022155_add_municipality_budget_notes` (o `.json` da migration também sara a cadeia de snapshots pós-`campaign_goals`); form de estratégia + zod + view model estendidos; **print CSS** (chrome `print:hidden`, shells `print:h-auto print:overflow-visible`, tipografia A4 em `styles.css`) — dossiê imprime 1–2 páginas com capa autocontida ("Gerado em"). Int test `municipalityDossierData.int.spec.ts` (mock do `unstable_cache` do baseline). Gate verde (tsc/lint/knip/363 tests/build); Aikido bloqueado por sessão expirada (re-login navegador pendente). Plano: [`dossie-municipio.md`](../../../docs/plans/dossie-municipio.md).
|
||||
- **E18 entregue em código (2026-07-24):** documentação de conceitos de inteligência — rota staff-only `/campanha/conceitos` (`isCampaignStaff`, `leader` redirecionado) renderizando `src/lib/campaignIntelligenceConcepts.ts`, conteúdo curado por PR ao lado das fórmulas que descreve (não collection Payload). 7 conceitos de E8 agrupados em base do cálculo (válidos projetados, teto do campo) / diagnóstico (captura, share intracampo, roll-off) / meta e cobertura (meta e meta sugerida, cobertura da meta), cada um com o que mede, fórmula em texto, exemplo estático, por que importa e onde aparece na tela. Superfície de leitura, não cards: `article` por conceito com hairline, coluna em `max-w-prose`, índice sticky só em `lg`, `article:target` tintado para orientar quem cai por âncora. Dois caminhos de descoberta: "Saiba mais" por métrica nos tooltips do card (`MetricExplanation` ganhou `conceptID`) e link para a página inteira no Popover `CampaignInfoHint` do título — o Popover é o caminho de **teclado**, porque conteúdo de Tooltip do Radix não é tabulável; para o toque funcionar, `MunicipalityHoverTooltip` passou a tratar o próprio conteúdo como "dentro" no dismiss por `pointerdown` (antes o link era desmontado antes do clique chegar). Entrada na sidebar a pedido do usuário na mesma sessão: `staffSecondaryNav` num `SidebarGroup mt-auto` colado no rodapé da navegação — fora da `staffNav` (os 7 destinos de trabalho) e fora da barra inferior do mobile, porque é referência e não lugar de trabalhar; `getCampaignSecondaryNav` devolve vazio para `leader`. Corrigido no caminho um defeito de E8: `formatElectionNumber` renderizava a meta sugerida fracionária como "100,968" (lida como cem mil) — agora `maximumFractionDigits: 0`. Sem migration, sem collection, sem server action. Cada fatia futura do programa (E9/E10/B13/E11/E12/E13/E14) acrescenta sua seção ao array. Plano: [`documentacao-conceitos-campanha.md`](../../../docs/plans/documentacao-conceitos-campanha.md).
|
||||
- **E9 entregue em código (2026-07-24):** fila de alocação na própria lista de municípios (sem rota nova, sem coluna nova, sem migration). **Fase 0 — correção do E8:** a meta sintética deixou de ser decomposição proporcional ao teto do campo (que dava 2.911 a Vitória da Conquista, de 5.005 votos em 2022, e 813 a Campo Formoso, de 47) e passou a ser ancorada na votação própria de 2022, por cenário — `deriveSuggestedGoalsByScenario` em [`municipalityPotential.ts`](../../../src/utilities/municipalityPotential.ts): pessimista = base×(1−`margin`), central = base ("repetir 2022"), otimista = base×(`stateGoal` ÷ Σ base), com clamp em `max(central, otimista)` quando `stateGoal` < Σ base; `goalCoverage.ts` passou a receber `SuggestedGoalByScenario` no lugar do escalar, deixando `expectedVotes[S] ?? suggestedGoal[S]` simétrico. `stateGoal`/`margin` do global `campaignGoals` mantêm papel de cálculo (fator 1,163 = "2022 mais 16%"; corte pessimista, fallback 10%) — só `admin.description` mudou, daí a ausência de migration. **Fase 1 — frescor e ordenações:** `declaredAt`/`estimatedAt` entraram no `select` do agregado de pledges (`lastPledgeAt`), `lastSignalAt = max(municipality.lastUpdateAt, lastPledgeAt)` no view model, e duas sort keys novas — `deficit` (**novo default do staff**, desc) e `frescor` (sinal mais frio primeiro, nunca-teve-sinal na frente), ambas derivadas em memória sobre o bundle já carregado, no cenário `central` fixo. **Fase 2 — UI (Impeccable B):** frescor dobrado na coluna "Última atualização" ("há N dias", frio a partir de 21 dias), badge "sem responsável" na célula de assessoria quando `priority === 'alta'` e zero assessores, "coluna da vergonha" como `detail` da métrica de assessoria linkando `?priority=alta&coverage=sem_assessor`, tooltip da célula de cobertura nomeando o cenário ativo, e correção de copy Praças→Municípios no overview. O `critique` trocou o tom do sinal frio de `text-destructive` para `text-estimate-pending-foreground`: com badge de prioridade e "sem responsável" na mesma linha, o vermelho perdia impacto. `/simplify` pós-entrega (validado por três revisores paralelos): consolidou o comparador "latest de dois ISO timestamps" (`resolveMunicipalityLastSignalAt`/`laterTimestamp` eram a mesma função escrita duas vezes) num `latestIsoTimestamp` compartilhado em `campaignTime.ts`; removeu o passthrough morto `baseTotal`/`growthFactor`/`belowBase` e renomeou `computeStatewideGoalDecomposition` → `computeStatewideSuggestedGoals` (nada mais é "decomposto" desde a Fase 0); unificou o badge de assessor duplicado entre tabela/card (tinham divergido em qual ícone renderiza); e consolidou três literais de "cenário zerado" num `zeroByVoteEstimateScenario()` em `voteEstimate.ts`. **Cortes registrados no plano:** votos em jogo → **B13**, LQ/captura → **E10**, coluna dedicada de déficit → desnecessária (a célula do E8 já mostra % + déficit assinado); `sanityCheckSuggestedGoalsByTerritory` permanece sem consumidor de produção (E11/E12). Verificação: tsc/lint/knip limpos, 337 unit + 366 int testes verdes, build verde; Aikido sem achado novo. Plano: [`fila-de-alocacao.md`](../../../docs/plans/fila-de-alocacao.md).
|
||||
- **B19 registrado (2026-07-24):** gerenciar assessores em `/campanha/assessores` (lista + novo + detalhe) — só Coordenador Geral e Candidato; criar/editar contas `advisor`, carteira de municípios, reenviar senha; alinha access update/phone do `candidate`. Sem migration. Plano: [`gerenciar-assessores.md`](../../../docs/plans/gerenciar-assessores.md).
|
||||
- **E8 entregue em código (2026-07-24):** conta da cadeira — artefato TSE commitado estendido para v2 (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022` presidente/governador T1 2022, via `pnpm build:election-aggregates`; teto de bytes revisado 400→700 KB) + curadoria `src/lib/campoParties.ts` (ano→siglas do campo, validada contra `electionPartySpectrum` por unit test); global `campaignGoals` (meta estadual 150 mil + margem, migration `20260724_180000_add_campaign_goals_global`, access staff-read/coordinator-write, **sem** hook de revalidação — `/campanha` é dinâmico com auth); `municipalityPotential.ts` (válidos projetados, teto do campo, captura, share intracampo, roll-off 2022-only, decomposição proporcional da meta + sanity por TI) e `goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = Σ pledges`, nunca a expectativa da mesa — semântica travada contra o conflito da auditoria); UI Impeccable classe B: linha "Cobertura da meta" no dashboard e no overview da lista, nova coluna na lista de municípios (renomeando "Cobertura"→"Assessoria" para eliminar ambiguidade), card "Conta da cadeira" no detalhe (fixo em `central`, sem seletor de cenário). `/simplify` pós-entrega: removida a checagem de sanidade por TI do caminho quente por requisição (fica exportada/testada para o E12 consumir), lookup de potencial por município virou O(1) (`Map` em vez de `.find`), loaders quase-idênticos de votos majoritários/deputado federal unificados, tipo de tally duplicado unificado, formatadores de percentual/progresso deduplicados entre dashboard/overview/card. Desbloqueia **E9/E10/E12/E13** no roadmap. **Follow-up mesmo dia (feedback do usuário):** tooltips hover/foco nos 4 números de diagnóstico do card (`MunicipalityHoverTooltip`, sem ícone de interrogação); seed local passou a importar presidente/governador também para 2014/2018 (artefato/fórmulas continuam 2022-only); `/impeccable critique` do card (2×P1 touch dead-end + colisão de layout, 2×P2 idiomas de disclosure/cópia densa, 1×P3 progress bar) endereçado por completo — tooltip agora abre/fecha por toque com dismiss por toque fora, nunca cobre "Cobertura da meta", cópia reescrita em frase-líder + fórmula sem cross-reference, barra de progresso mais visível a 0%; registrado **E18** (documentação de conceitos de inteligência de campanha, plano futuro). Plano: [`conta-da-cadeira.md`](../../../docs/plans/conta-da-cadeira.md).
|
||||
- **C12 Registro-fundação entregue em código (2026-07-24):** trajetória de `votePledge` por versions nativas com baseline; atualização municipal `sinal` tipada (explicações, fonte e triangulação); `actionPlan.origin`; demandas vinculadas ao plano com custo derivado e criação múltipla atômica no formulário; `allocationDecision` ex-ante imutável com snapshot pequeno. Migration consolidada `20260724_180000_add_campaign_foundation_records`; testes de integração e jornada E2E dedicados.
|
||||
|
|
|
|||
|
|
@ -67,7 +67,10 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
|
||||
**Recently resolved (2026-07-25):** **E16** Dossiê do município (pré-agenda, field request O6) — staff-only `dossie` tab on the municipality detail page (6th value in `municipalityDetailTabs`, same query-param pattern): `src/utilities/municipalityDossierData.ts` composes the existing loaders in one pass (TSE baseline + A11 rank via `municipalityElectionGeographyForSlug`, E8 goal account, pledges, leaderships sorted by a new `LeadershipRowViewModel.updatedAt` freshness field, updates feed, two scoped `actionPlan` reads with `overrideAccess: false`, and A8 demographics — the previously consumer-less `bahiaMunicipalityDemographics.ts` re-enters through the "Perfil" section, with an explicit whole-city caveat for Salvador zona municipalities). Section caps 8 lideranças / 5 sinais / 3 upcoming + 2 realizado plans, each with a "ver tudo" link to its tab. New staff-only `municipality.budgetNotes` textarea ("Emendas aportadas", G11 manual-first) via migration `20260725_022155_add_municipality_budget_notes` — its committed `.json` snapshot also heals the drizzle snapshot chain after the snapshot-less `campaign_goals` migration; strategy form/zod/action extended; the section renders only when filled. **Print view is CSS-only:** app chrome gets `print:hidden`, the `h-svh`/`overflow-hidden` shells get `print:h-auto print:overflow-visible` (otherwise only page 1 prints), and `styles.css` gains A4 print typography; the dossier cover is self-contained (name, TI, badges, "Gerado em") so the printed page keeps its identity. Int test mocks `loadMunicipalityElectoralBaseline` (its `unstable_cache` wrapper needs the Next runtime). Plano: [`docs/plans/dossie-municipio.md`](docs/plans/dossie-municipio.md).
|
||||
|
||||
**Recently resolved (2026-07-24):** **E8** Conta da cadeira (metas derivadas, potencial por município, cobertura) — committed TSE artifact extended to v2 (`pnpm build:election-aggregates` now writes `campoFederalVotesByYear`, `federalTallyByYear`, and 2022 `majoritarian2022` presidente/governador T1 per municipality, alongside the existing `votesByYear`/`validVotesByYear`; byte budget raised 400→700 KB in the unit test); `src/lib/campoParties.ts` (curated field-party siglas per election year, unit-tested against `electionPartySpectrum.ts`'s `esquerda` bucket — not a second party classifier); global `campaignGoals` (`src/globals/CampaignGoals.ts`, migration `20260724_180000_add_campaign_goals_global`, `stateGoal` default 150,000 + `margin`/`baseYear`/`note`, `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`, **no** `afterChange` hook — `/campanha` is dynamic with auth so nothing caches it); pure derived utilities `src/utilities/municipalityPotential.ts` (projected valid votes, field ceiling, capture rate, intra-field share, 2022-only roll-off, proportional goal decomposition + TI sanity check) and `src/utilities/goalCoverage.ts` — **locked semantics:** `meta = expectedVotes[scenario] ?? suggestedGoal`, `comprometido = aggregate.effectiveByScenario[scenario]` (pledges only — the mesa's own expectation never substitutes for the pledge sum in the coverage denominator, unlike `resolveMunicipalityStaffVoteTotal`); orchestration in `src/utilities/municipalityGoalAccount.ts` (`cache()`-deduplicated across dashboard/list/detail). UI (Impeccable class B): "Cobertura da meta" row on the dashboard and the list overview `CampaignMetricStrip`, a new list column (renaming the old "Cobertura" column to "Assessoria" to remove ambiguity) via a shared `MunicipalityListGoalCoverageCell` (compact/default layouts), and a new "Conta da cadeira" card on the municipality detail page (fixed to the `central` scenario, replacing the "Votos estimados" block that used to live in `MunicipalityStrategyCard`). Unblocks roadmap items **E9/E10/E12/E13**. Plano: [`docs/plans/conta-da-cadeira.md`](docs/plans/conta-da-cadeira.md).
|
||||
**Recently resolved (2026-07-24):** **E8** Conta da cadeira (metas derivadas, potencial por município, cobertura) — committed TSE artifact extended to v2 (`pnpm build:election-aggregates` now writes `campoFederalVotesByYear`, `federalTallyByYear`, and 2022 `majoritarian2022` presidente/governador T1 per municipality, alongside the existing `votesByYear`/`validVotesByYear`; byte budget raised 400→700 KB in the unit test); `src/lib/campoParties.ts` (curated field-party siglas per election year, unit-tested against `electionPartySpectrum.ts`'s `esquerda` bucket — not a second party classifier); global `campaignGoals` (`src/globals/CampaignGoals.ts`, migration `20260724_180000_add_campaign_goals_global`, `stateGoal` default 150,000 + `margin`/`baseYear`/`note`, `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`, **no** `afterChange` hook — `/campanha` is dynamic with auth so nothing caches it); pure derived utilities `src/utilities/municipalityPotential.ts` (projected valid votes, field ceiling, capture rate, intra-field share, 2022-only roll-off, per-scenario suggested goal + TI sanity check) and `src/utilities/goalCoverage.ts` — **locked semantics:** `meta = expectedVotes[scenario] ?? suggestedGoal[scenario]`, `comprometido = aggregate.effectiveByScenario[scenario]` (pledges only — the mesa's own expectation never substitutes for the pledge sum in the coverage denominator, unlike `resolveMunicipalityStaffVoteTotal`); orchestration in `src/utilities/municipalityGoalAccount.ts` (`cache()`-deduplicated across dashboard/list/detail). UI (Impeccable class B): "Cobertura da meta" row on the dashboard and the list overview `CampaignMetricStrip`, a new list column (renaming the old "Cobertura" column to "Assessoria" to remove ambiguity) via a shared `MunicipalityListGoalCoverageCell` (compact/default layouts), and a new "Conta da cadeira" card on the municipality detail page (fixed to the `central` scenario, replacing the "Votos estimados" block that used to live in `MunicipalityStrategyCard`). Unblocks roadmap items **E9/E10/E12/E13**. Plano: [`docs/plans/conta-da-cadeira.md`](docs/plans/conta-da-cadeira.md). **As-built correction (same day, during E9):** the suggested goal originally split the state goal proportionally to the field ceiling alone, which ignored the candidate's own vote and produced absurd targets (2,911 for Vitória da Conquista, which gave him 5,005 votes in 2022; 813 for Campo Formoso, which gave him 47). `decomposeStateGoal` was deleted in favour of `deriveSuggestedGoalsByScenario`, anchored on the municipality's own 2022 vote — pessimistic = base × (1 − `margin`), central = base, optimistic = base × (`stateGoal` ÷ Σ base), clamped at `max(central, optimistic)` — so `stateGoal` now defines the optimistic scenario and `margin` the pessimistic haircut (10% fallback). Descriptions only, **no migration**.
|
||||
|
||||
**Recently resolved (2026-07-24):** **E9** Fila de alocação — the municipality list became the allocation queue, with **no new route, no new column and no migration** (the column picker is B17 and is not shipped, so every new signal folds into an existing cell). Ordering: two new sort keys in `municipalityUi.ts` — `deficit` (**the new staff default**, descending) and `frescor` — both derived in memory in `applyDerivedMunicipalitySort` over the already-loaded bundle, fixed to the `central` scenario server-side (moving the scenario into the URL is the "Cenário junto aos filtros" fill-in). Freshness: `aggregatePledgesByMunicipality` now selects `declaredAt`/`estimatedAt` and exposes `lastPledgeAt`; `resolveMunicipalityLastSignalAt` (max of `municipality.lastUpdateAt` and `lastPledgeAt`) feeds both the comparator and the view model's `lastSignalAt`, so server ordering and rendered copy cannot drift; cold threshold `MUNICIPALITY_COLD_SIGNAL_DAYS` = 21. UI: "há N dias" in the existing "Última atualização" column (amber `text-estimate-pending-foreground`, deliberately not `destructive` — the priority and "sem responsável" badges already compete for red on the same row), a "sem responsável" badge when `priority === 'alta'` with zero advisors, the "coluna da vergonha" as the advisor-coverage metric's detail linking `?priority=alta&coverage=sem_assessor`, a scenario-named coverage tooltip, and the leftover "Praças" copy fixed to "Municípios". Cut and re-homed: votes at stake → **B13**, LQ/capture → **E10**, a dedicated deficit column → unnecessary (the E8 cell already shows % and signed deficit). Plano: [`docs/plans/fila-de-alocacao.md`](docs/plans/fila-de-alocacao.md).
|
||||
|
||||
|
||||
**Recently resolved (2026-07-24):** **E18** Documentação dos conceitos de inteligência — staff-only route `/campanha/conceitos` (`isCampaignStaff`; `leader` redirected to `/campanha`) rendering `src/lib/campaignIntelligenceConcepts.ts`. The glossary is **curated content in code, not a Payload collection**: each entry (what it measures, the formula in plain text, a hand-written example, why it matters, where it appears) sits next to the formulas it documents (`municipalityPotential.ts` / `goalCoverage.ts`) and is reviewed in the same PR that changes them; a unit test guards anchor-safe unique IDs and content completeness. Seven E8 concepts in three groups — base do cálculo (válidos projetados, teto do campo), diagnóstico (captura, share intracampo, roll-off), meta e cobertura (meta/meta sugerida, cobertura da meta). Reading surface, not cards: one `article` per concept with hairline dividers, `max-w-prose` column, sticky index at `lg` only, `article:target` tint so anchor arrivals land oriented. Two discovery paths from `MunicipalityGoalAccountCard`: per-metric "Saiba mais" links inside the hover tooltips (`MetricExplanation` takes a `conceptID`) and a whole-page link in the title `CampaignInfoHint` **Popover** — the Popover is the keyboard path because Radix Tooltip content is not tabbable; for touch, `MunicipalityHoverTooltip` now treats its own content as "inside" in the `pointerdown` dismiss handler (the link used to unmount before the tap landed). Sidebar entry lives in `staffSecondaryNav` (`nav.ts`) rendered in its own `SidebarGroup` with `mt-auto` at the foot of the navigation — deliberately outside `staffNav` (the seven work destinations) and outside the mobile bottom bar, since it is reference material; `getCampaignSecondaryNav` returns nothing for `leader`. Fixed an E8 defect found while writing the docs: `formatElectionNumber` rendered a fractional suggested goal as "100,968" (read as a hundred thousand in pt-BR) — now `maximumFractionDigits: 0`. No migration, no collection, no server action; later slices of the intelligence program (E9/E10/B13/E11/E12/E13/E14) each append their section to the array. Plano: [`docs/plans/documentacao-conceitos-campanha.md`](docs/plans/documentacao-conceitos-campanha.md).
|
||||
|
||||
|
|
@ -130,7 +133,7 @@ The internal `/campanha` area is gated by its own authentication, deliberately k
|
|||
`/campanha` is organized around `municipality` ("Município") — the campaign's PREDEFINED operational territory. Deployed to production on 2026-07-23 (destructive remodel migrations applied via the Vercel build). Master plan: `docs/plans/remodelagem-municipios.md` (supersedes `docs/plans/remodelagem-pracas.md`).
|
||||
|
||||
- **Município definition:** 435 seeded rows — one per Bahia municipality (416), except Salvador (19 zone municipalities, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated). Canonical catalog: `src/lib/municipalityCatalog.ts` (`tests/fixtures/municipality-catalog.snapshot.json`). Seeded by migration `20260723_200000_remodel_municipalities` (+ reconcile `20260723_202000_reconcile_municipality_remodel`). Geography is admin-only; staff edit strategy fields and the coordinator assigns `advisors`.
|
||||
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `municipalityUpdate`, `campaignInvite`, `supporter`, `actionPlan`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — state-level vote goal + margin; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it).
|
||||
- **Collections (admin group `Campanha`):** `municipality`, `leadership`, `votePledge`, `organization`, `stateDeputy` (dobradinhas), `campaignDemand`, `municipalityUpdate`, `campaignInvite`, `supporter`, `actionPlan`. **Global** (same admin group): `campaignGoals` (E8 "conta da cadeira" — `stateGoal` sets the optimistic suggested-goal scenario, `margin` the pessimistic haircut; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; no `afterChange` hook — nothing caches it).
|
||||
- **Roles:** `coordinator` ("Coordenador Geral") and `candidate` ("Candidato", unrestricted visibility via `isCampaignUnrestricted`) see/manage everything staff sees; `advisor` ("Assessor") is scoped to `municipality.advisors`; `leader` ("Liderança") is **lockdown** — home is the supporter contact tool only (`LeaderContactsPanel`, `source: lideranca`), reads only supporters they created (`createdBy`), no municipalities/leaderships/pledges/demands/plans/election data.
|
||||
- **Vote pledge asymmetry:** staff declare `declaredVotes` and record `estimatedVotes` (pessimistic/central/optimistic); leaders never see estimates. Aggregates use `estimated[S] ?? declared`, default `central` (`src/utilities/votePledgeData.ts`).
|
||||
- **Demandas:** staff-only create/read (`canCreateCampaignDemand`); optional `leadership` provenance selected by staff. Escalated decisions: coordinator **or** candidate.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ Ajustes ao plano abaixo, travados nesta sessão:
|
|||
4. **`campoParties.ts` não é uma segunda fonte de verdade de partido.** Já existe [electionPartySpectrum.ts](../../src/lib/electionPartySpectrum.ts) (buckets esquerda/centro/direita). `campoParties.ts` é a curadoria de **campo por ano** (2014/2018/2022/2026); um unit test prova que toda sigla do campo está no bucket `esquerda`, com exclusões documentadas.
|
||||
5. **Access de global é terreno novo.** Os 4 globals existentes (`SiteSettings`, `HomePage`, `Metadata`, `PrivacyPolicy`) usam `read: () => true` / `update: Boolean(user)`; nenhum está no grupo `Campanha` nem nega leitura. `campaignGoals` precisa de access explícito (JWT de `campaignUser` alcança `/api/globals/*`).
|
||||
|
||||
Decisões de produto travadas na mesma sessão (detalhes na seção "Decisões travadas" e "Questões em aberto" abaixo, já atualizadas): meta estadual inicial 150.000 (editável); válidos projetados = média ponderada `(v2014 + v2018 + 2×v2022) / 4`; decomposição proporcional ao teto do campo projetado (não fórmula composta); teto do campo = presidencial do campo T1 2022 (#13) como primário, governador como leitura secundária (assumido, validar com produto); roll-off = (brancos+nulos DF) − (brancos+nulos majoritária), 2022-only.
|
||||
Decisões de produto travadas na mesma sessão (detalhes na seção "Decisões travadas" e "Questões em aberto" abaixo, já atualizadas): meta estadual inicial 150.000 (editável); válidos projetados = média ponderada `(v2014 + v2018 + 2×v2022) / 4`; decomposição proporcional ao teto do campo projetado (não fórmula composta) — **revista no mesmo dia**, ver "Revisão da fórmula da meta sintética" abaixo: a meta passou a ser ancorada na votação própria de 2022, por cenário; teto do campo = presidencial do campo T1 2022 (#13) como primário, governador como leitura secundária (assumido, validar com produto); roll-off = (brancos+nulos DF) − (brancos+nulos majoritária), 2022-only.
|
||||
|
||||
## Entregue (as-built, 2026-07-24)
|
||||
|
||||
|
|
@ -27,10 +27,27 @@ Todas as 6 fases implementadas conforme o plano de execução, com os ajustes da
|
|||
|
||||
- **Artefato v2** (`pnpm build:election-aggregates`): `campoFederalVotesByYear`, `federalTallyByYear` e `majoritarian2022` (presidente/governador T1 2022) adicionados por município a `bahia-federal-baseline.json`, ao lado de `votesByYear`/`validVotesByYear`. Curadoria `src/lib/campoParties.ts` (2014/2018/2022/2026), validada por unit test contra o bucket `esquerda` de `electionPartySpectrum.ts`. Teto de bytes do artefato revisado de 400 KB para 700 KB (`tests/unit/bahiaElectionAggregates.unit.spec.ts`).
|
||||
- **Global `campaignGoals`** (`src/globals/CampaignGoals.ts`, migration `20260724_180000_add_campaign_goals_global`): `stateGoal` (default 150.000), `margin`, `baseYear`, `note`; `read` = admin/staff, `update` = admin/`isCampaignUnrestricted`; **sem** hook `afterChange` (achado #4 da auditoria — cerimônia morta, `/campanha` é dinâmico com auth). Loader `src/utilities/campaignGoals.ts` com React `cache()`.
|
||||
- **Derivados puros**: `src/utilities/municipalityPotential.ts` (`projectedValidVotes`, `fieldCeiling`, `projectedFieldCeiling`, `captureRate`, `intraFieldShare`, `rollOff`, `decomposeStateGoal`, `sanityCheckSuggestedGoalsByTerritory`) e `src/utilities/goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = aggregate.effectiveByScenario[cenário]`, nunca a expectativa da mesa — achado #2). Orquestração server-scoped em `src/utilities/municipalityGoalAccount.ts` (`cache()`-deduplicado entre dashboard/lista/detalhe).
|
||||
- **Derivados puros**: `src/utilities/municipalityPotential.ts` (`projectedValidVotes`, `fieldCeiling`, `projectedFieldCeiling`, `captureRate`, `intraFieldShare`, `rollOff`, `deriveSuggestedGoalsByScenario` — originalmente `decomposeStateGoal`, revista no mesmo dia (ver "Revisão da fórmula da meta sintética" abaixo) —, `sanityCheckSuggestedGoalsByTerritory`) e `src/utilities/goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = aggregate.effectiveByScenario[cenário]`, nunca a expectativa da mesa — achado #2). Orquestração server-scoped em `src/utilities/municipalityGoalAccount.ts` (`cache()`-deduplicado entre dashboard/lista/detalhe).
|
||||
- **UI** (Impeccable classe B, shape → craft → critique → harden): linha "Cobertura da meta" na `CampaignMetricStrip` do dashboard (`CampaignDashboard.tsx`) e do overview da lista (`MunicipalityListOverview.tsx`); nova coluna "Cobertura da meta" em `MunicipalityList.tsx` (a coluna antiga "Cobertura" foi renomeada para "Assessoria" para eliminar a ambiguidade), com célula compartilhada `MunicipalityListGoalCoverageCell.tsx` (variante `compact` para a tabela desktop, `default` para os cards mobile); card novo "Conta da cadeira" (`MunicipalityGoalAccountCard.tsx`) na aba Visão geral do detalhe do município, fixo no cenário `central` (sem seletor de cenário nessa página) — substitui o bloco "Votos estimados" que antes vivia em `MunicipalityStrategyCard.tsx`.
|
||||
- **Verificação:** `tsc --noEmit`, `pnpm lint`, `pnpm exec knip`, unit + int (incl. `tests/int/campaignGoalsAccess.int.spec.ts` cobrindo a matriz de acesso por papel) e `pnpm build` verdes contra o banco local; `pnpm test:e2e` verde quando executado isolado (flakiness de timeout/`ECONNRESET` observada só sob contenção de recursos do sandbox com múltiplos dev servers em paralelo — não é regressão de código; 1 assertion ajustada para um seletor Playwright não-ambíguo pós-mudança de UI).
|
||||
- **Divergências do plano original:** nenhuma decisão de produto mudou; os 6 achados da auditoria (seção acima) e as decisões travadas na sessão foram seguidos à risca. `territoryWarnings` (sanity check por TI) ficam computados e disponíveis em `municipalityPotential.ts`/`computeStatewideGoalDecomposition`, mas **não** ganharam superfície de UI nesta entrega — o brief de shape aprovado (Fase 4a) cobriu só as 4 integrações (dashboard, overview, lista, detalhe); exibir os avisos por TI é candidato natural para E12 (camada TI) ou um fill-in futuro.
|
||||
- **Divergências do plano original:** nenhuma decisão de produto mudou; os 6 achados da auditoria (seção acima) e as decisões travadas na sessão foram seguidos à risca. `territoryWarnings` (sanity check por TI) ficam computados e disponíveis em `municipalityPotential.ts`, mas **não** ganharam superfície de UI nesta entrega — o brief de shape aprovado (Fase 4a) cobriu só as 4 integrações (dashboard, overview, lista, detalhe); exibir os avisos por TI é candidato natural para E12 (camada TI) ou um fill-in futuro.
|
||||
|
||||
### Revisão da fórmula da meta sintética (2026-07-24, durante o E9)
|
||||
|
||||
A auditoria de implementação do **E9** ([fila-de-alocacao.md](fila-de-alocacao.md)) encontrou um erro material nesta entrega e o corrigiu antes de construir a fila. `decomposeStateGoal` repartia os 150 mil proporcionalmente **só ao teto do campo** (voto de Lula), ignorando a votação própria do candidato: Vitória da Conquista, com 5.005 votos dele em 2022, recebia meta 2.911; Campo Formoso, com 47 votos, recebia 813. Como o E9 ordena a fila por déficit descoberto, essa meta colocaria desertos no topo — contra o padrão P6 do relatório ("se nunca tive voto, é deserto… despriorizar por escrito").
|
||||
|
||||
A meta sugerida passou a ser derivada por cenário e ancorada na votação própria de 2022 (`deriveSuggestedGoalsByScenario` substitui `decomposeStateGoal`, que foi deletada):
|
||||
|
||||
```
|
||||
base_i = votação própria de 2022 no município
|
||||
pessimista_i = base_i × (1 − margin) Σ = 116.071
|
||||
central_i = base_i Σ = 128.968 ("repetir 2022")
|
||||
otimista_i = base_i × (stateGoal ÷ Σ base) Σ = 150.000 (exato, por construção)
|
||||
```
|
||||
|
||||
Consequências para este plano: `computeGoalCoverage`/`computeGoalCoverageByScenario` recebem `SuggestedGoalByScenario` no lugar do escalar (a semântica travada `meta = expectedVotes[S] ?? suggestedGoal[S]` fica simétrica dos dois lados); `stateGoal` deixa de ser "piso decomposto" e vira o **cenário otimista** (o fator 1,163 traduz "meta de 150 mil" em "2022 mais 16% em todo lugar"), enquanto `margin` deixa de ser leitura da mesa e vira o **corte pessimista** (fallback 10%) — só `admin.description` do global mudou, sem migration. Se `stateGoal` < Σ base, o otimista clampa em `max(central, otimista)`. `sanityCheckSuggestedGoalsByTerritory` fica praticamente inerte com a meta ancorada no histórico; o teto de plausibilidade que a literatura pede (soma de pledges acima do teto do campo) é sinal de **pledge**, não de meta, e foi adiado para o E11.
|
||||
|
||||
Racional na literatura do projeto: `docs/research/relatorio-entrevista-persona-campanha.md` define potencial como "voto histórico próprio mais fração do campo não capturado" (linhas 323/331), com pesos por classe operacional — e classe operacional é escopo do **E10**, não do E8.
|
||||
|
||||
### Follow-up pós-entrega (mesmo dia, feedback do usuário)
|
||||
|
||||
|
|
@ -57,7 +74,7 @@ O relatório de discovery ([§5.1](../research/relatorio-entrevista-persona-camp
|
|||
## Objetivos
|
||||
|
||||
- Global `campaignGoals` (admin group `Campanha`, staff-only): meta estadual de votos, margem, ano-base, nota.
|
||||
- Utilities derivadas por município (sem persistir o derivado): válidos projetados do cargo (série 2014/2018/2022), teto do campo (majoritário 1º turno), taxa de captura, share intracampo (via `campoParties.ts`), roll-off DF×majoritária, potencial e meta sugerida (decomposição proporcional ao potencial).
|
||||
- Utilities derivadas por município (sem persistir o derivado): válidos projetados do cargo (série 2014/2018/2022), teto do campo (majoritário 1º turno), taxa de captura, share intracampo (via `campoParties.ts`), roll-off DF×majoritária, potencial e meta sugerida (na entrega: ancorada na votação de 2022 por cenário — ver "Revisão da fórmula da meta sintética").
|
||||
- Cobertura por município e agregada, para os três cenários: `meta = expectedVotes[cenário] ?? suggestedGoal` (derivado); `comprometido = aggregate.effectiveByScenario[cenário]` (só Σ pledges — declarado/estimado por leadership, nunca a expectativa da mesa); `coverageRatio = comprometido ÷ meta`. Cenário ativo escolhido no cliente (`MunicipalityEstimateScenarioProvider`, default `central`). Delta semanal (exige C12 para série; até lá, delta vs. snapshot em memória do último load é aceitável mostrar como "—").
|
||||
- Encaixes: linha de cobertura no dashboard (`campaignDashboardData.ts`), colunas meta/cobertura na lista (`MunicipalityList`/`MunicipalityListOverview`), card no detalhe do município.
|
||||
- Access: tudo staff-only (`coordinator`/`advisor`); `leader` não vê metas — mesmo padrão de `expectedVotes`.
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ Componentes: `src/utilities/municipalityDossierData.ts` (novo — orquestra load
|
|||
|
||||
## Explicitamente fora (triage pós-simplify 2026-07-25)
|
||||
|
||||
- Memoização em module-scope de `computeStatewideGoalDecomposition` — pré-existente E8, fora do diff do dossiê (revisitar no plano [conta-da-cadeira.md](conta-da-cadeira.md) se o hot path do dashboard doer).
|
||||
- Memoização em module-scope de `computeStatewideSuggestedGoals` — pré-existente E8, fora do diff do dossiê (revisitar no plano [conta-da-cadeira.md](conta-da-cadeira.md) se o hot path do dashboard doer).
|
||||
- Encolher o page size do feed de sinais de 10 → 5 — o loader compartilhado precisa de `totalDocs`; micro-otimização sem evidência.
|
||||
- Critique do `MunicipalityGoalAccountCard` (tooltips touch) — sessão E8, já hardenido no follow-up #3 daquele plano; não é dívida do E16.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,35 @@
|
|||
# E9 — Fila de alocação (lista de decisão do coordenador)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-24 (refs sincronizadas pós-remodelagem Municípios + hardening)
|
||||
Status: entregue em código (2026-07-24; ver "Entregue (as-built)" abaixo)
|
||||
Atualizado em: 2026-07-24 (entrega registrada — escopo cortado e correção da fórmula do E8)
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (seção "Inteligência de campanha", E9; plano-mestre [inteligencia-campanha.md](inteligencia-campanha.md))
|
||||
Impeccable: B — visão nova DENTRO de `/campanha/municipios` (ordenações + colunas + painel), sem rota nova
|
||||
Appetite: ~1,5 dia eng; sem migration (deriva de E8 + dados existentes)
|
||||
Responsável: —
|
||||
|
||||
## Entregue (as-built, 2026-07-24)
|
||||
|
||||
A fila é a lista de municípios existente com duas ordenações novas e três sinais dobrados em células que já existiam — **sem rota nova, sem coluna nova e sem migration**. A auditoria antes do craft encontrou um problema material na dependência: a meta sintética do E8 era decomposta proporcionalmente ao **teto do campo** (voto de Lula), ignorando a votação própria, o que dava meta 2.911 a Vitória da Conquista (5.005 votos dele em 2022) e 813 a Campo Formoso (47 votos). Ordenar por déficit sobre essa meta colocaria desertos no topo — o oposto do que o item existe para fazer, e contra o padrão P6 do relatório. Corrigir a fórmula virou a Fase 0 desta entrega.
|
||||
|
||||
**Fase 0 — meta sintética por cenário, ancorada em 2022** ([municipalityPotential.ts](../../src/utilities/municipalityPotential.ts), [goalCoverage.ts](../../src/utilities/goalCoverage.ts), [municipalityGoalAccount.ts](../../src/utilities/municipalityGoalAccount.ts)):
|
||||
|
||||
```
|
||||
base_i = votação própria de 2022 no município (artefato TSE commitado)
|
||||
pessimista_i = base_i × (1 − margin) Σ = 116.071
|
||||
central_i = base_i Σ = 128.968 ("repetir 2022")
|
||||
otimista_i = base_i × (stateGoal ÷ Σ base) Σ = 150.000 (exato)
|
||||
```
|
||||
|
||||
`decomposeStateGoal` foi substituída por `deriveSuggestedGoalsByScenario`; `computeGoalCoverage`/`computeGoalCoverageByScenario` passaram a receber `SuggestedGoalByScenario` no lugar do escalar, deixando `expectedVotes[S] ?? suggestedGoal[S]` simétrico nos dois lados. Guarda implementada: se `stateGoal` < Σ base, o otimista clampa em `max(central, otimista)`. Os três totais caem na faixa realista da cadeira (80–150 mil, relatório linha 323) e o déficit central passa a medir o indicador que a literatura nomeia — "% do voto histórico da praça coberto por liderança ativa". Campo Formoso vira 42/47/55. `stateGoal` e `margin` do global `campaignGoals` mantiveram papel de cálculo (o fator 1,163 traduz "meta de 150 mil" em "2022 mais 16% em todo lugar"; `margin` vira o corte pessimista, fallback 10%) — só `admin.description` mudou, daí não haver migration.
|
||||
|
||||
**Fase 1 — frescor e ordenações** ([votePledgeData.ts](../../src/utilities/votePledgeData.ts), [municipalityUi.ts](../../src/utilities/municipalityUi.ts), [municipalityPageData.ts](../../src/utilities/municipalityPageData.ts), [municipalityViewModels.ts](../../src/utilities/municipalityViewModels.ts)): `declaredAt`/`estimatedAt` entraram no `select` do agregado (`lastPledgeAt`); `lastSignalAt = max(municipality.lastUpdateAt, lastPledgeAt)` no view model, com o mesmo helper (`resolveMunicipalityLastSignalAt`) servindo o comparador do servidor e a leitura da UI, para não divergirem; sort keys `deficit` (**novo default do staff**, desc) e `frescor` (mais frio primeiro, "nunca teve sinal" na frente), ambas derivadas em memória sobre o bundle já carregado. A ordenação usa o cenário `central` fixo no servidor — o seletor de cenário é client-side, e movê-lo para a URL é o fill-in "Cenário junto aos filtros".
|
||||
|
||||
**Fase 2 — UI (Impeccable B)** ([MunicipalityList.tsx](../../src/components/campaign/MunicipalityList.tsx), [MunicipalityListOverview.tsx](../../src/components/campaign/MunicipalityListOverview.tsx), [MunicipalityListGoalCoverageCell.tsx](../../src/components/campaign/MunicipalityListGoalCoverageCell.tsx)): frescor dobrado na coluna "Última atualização" ("há N dias"; frio a partir de `MUNICIPALITY_COLD_SIGNAL_DAYS` = 21); badge "sem responsável" na célula de assessoria quando `priority === 'alta'` e zero assessores; "coluna da vergonha" como `detail` da métrica "Cobertura de assessoria", linkando `?priority=alta&coverage=sem_assessor`; tooltip da célula de cobertura nomeando o cenário ativo; copy "Praças" → "Municípios" corrigida. O `critique` trocou o tom do sinal frio de `text-destructive` para `text-estimate-pending-foreground`: com badge de prioridade e badge "sem responsável" possíveis na mesma linha, o vermelho deixava de significar urgência.
|
||||
|
||||
**Escopo cortado (e para onde foi):** votos em jogo → **B13** (que já prevê símbolo proporcional por votos em jogo); LQ/captura → **E10** (classe operacional e pesos por classe são daquele item); coluna dedicada de déficit → desnecessária, a célula do E8 já mostra % e déficit assinado; seletor de colunas → **B17**, não entregue, e é por isso que nenhum sinal novo virou coluna.
|
||||
|
||||
**Verificação:** `tsc --noEmit`, `pnpm lint` (zero warnings) e `pnpm exec knip` limpos; 365 testes unit+int verdes (inclui unit do comparador e das três somas da meta, int de `deficit`/`frescor`, e2e cobrindo default + coluna de frescor); e2e 9 passed + 1 flaky (flake pré-existente de carga da máquina, verde no retry); `pnpm build` contra o banco local verde; Aikido sem achado novo (o único achado, object-injection em `municipalityPotential.ts`, está em linha pré-existente do E8, fora do diff).
|
||||
|
||||
## Design (Impeccable)
|
||||
|
||||
Âncoras: `PRODUCT.md` (princípios 2 "clarity under pressure", 3 "edit where you see", 5) / `DESIGN.md` (register `product`) · tema `data-theme='campaign'` · shells existentes (`MunicipalityList`, `MunicipalityListOverview`, `MunicipalityFilters`, `CampaignListPagination`).
|
||||
|
|
@ -40,8 +63,8 @@ O relatório definiu a "fila de trabalho" com 7 colunas ranqueadas (FU3): votos
|
|||
|
||||
## Questões em aberto
|
||||
|
||||
- **Frescor conta o quê?** Opções: só `declaredAt`/`estimatedAt` de pledges | máx(pledge, `municipality.lastUpdateAt`) | incluir planos. **Recomendação:** máx(pledge mais recente, `lastUpdateAt`) — sinais de rede já alimentam `lastUpdateAt` via `municipalityUpdate`; planos entram quando C12 tipar sinais.
|
||||
- **Colunas visíveis por default no mobile?** **Recomendação:** déficit + cobertura + frescor (3), resto em disclosure — campo usa celular (PRODUCT.md).
|
||||
- ~~**Frescor conta o quê?**~~ Resolvido na entrega: máx(pledge mais recente, `municipality.lastUpdateAt`). Planos entram quando houver demanda — C12 já tipou sinais, mas incluí-los alargaria "frescor" sem pedido de campo.
|
||||
- ~~**Colunas visíveis por default no mobile?**~~ Não se aplica ao as-built: nenhum sinal novo virou coluna, todos dobraram em células existentes (o card mobile mostra frescor e badge junto do que já mostrava).
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Appetite: ~0,5–1 dia eng; sem migration, sem collection, sem server action nov
|
|||
Responsável: —
|
||||
|
||||
Revisões:
|
||||
- 2026-07-24 (entrega + polish pós-critique): helper `municipalityVoteRank`; baseline + coluna lista; default da lista = `votos` desc (omitido na URL); `?sort=name` para alfabético; share lidera a célula; rank no mobile; % dos válidos demoted. B15 amplia keys restantes.
|
||||
- 2026-07-24 (entrega + polish pós-critique): helper `municipalityVoteRank`; baseline + coluna lista; default da lista = `votos` desc (omitido na URL) — **substituído pelo default `deficit` desc do E9 ✓ no mesmo dia**, `?sort=votos` continua disponível; `?sort=name` para alfabético; share lidera a célula; rank no mobile; % dos válidos demoted. B15 amplia keys restantes.
|
||||
- 2026-07-24 (auditoria pre-implementação): B15 ainda ausente no código — esta entrega introduz `sort`/`dir` mínimo só para `name`|`votos` (fallback do plano); B15 amplia as demais keys depois. Seção Dados → decisão → apresentação preenchida. Questões em aberto fechadas: lista só 2022; sem delta de rank 2018→2022 na v1.
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-25 (**E16 ✓** dossiê do município — aba + print + `budgetNotes` G11; anterior 2026-07-24: B19 gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueia E9/E10/E12/E13; **E18 ✓** documentação de conceitos (`/campanha/conceitos`); C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
|
||||
Atualizado em: 2026-07-25 (**E16 ✓** dossiê do município — aba + print + `budgetNotes` G11; **E9 ✓** fila de alocação — desbloqueia E11, e revisou a fórmula da meta sugerida do E8; **E18 ✓** documentação de conceitos (`/campanha/conceitos`); anterior 2026-07-24: B19 gerenciar assessores; **E8 ✓** conta da cadeira — desbloqueou E9/E10/E12/E13; C12 ✓; C11 absorve escala plano↔demandas; B18; B17; B16; reorder DnD fora de escopo; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6)
|
||||
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).
|
||||
|
||||
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
|
||||
|
|
@ -47,7 +47,8 @@ Textos provisórios de Consent + `/privacidade` auto-provisionados ([onda-0.md](
|
|||
- **E4R (2026-07-24)** — import único da planilha de projeção → `municipality.expectedVotes` (Bom→otimista, Regular→média, Mínimo→pessimista) + `priority` (`pnpm db:seed:projecao`, always-overwrite, dry-run + runbook; Salvador pulado; zero PII). No mesmo dia o grupo duplicado `voteGoals` ("Meta Bom/Regular/Mínimo") foi **removido do app** (migration `20260724_133600` com backfill metas→estimativas) — a única série por cenário é `expectedVotes`. Plano: [import-planilha-projecao.md](plans/import-planilha-projecao.md). Seed local verificado (189 estimativas / 50 alta); produção após smoke.
|
||||
- **E17 (2026-07-24)** — tabela comparativa dos 27 Territórios de Identidade no Início staff (`/campanha`): `territoryOverview.ts` (rollup puro client-safe: `computeTerritoryRollup` + `sortTerritoryRows`) + `loadTerritoryOverview.ts` (loader server-only, `overrideAccess: true`) + `TerritoryOverviewTable.tsx` (tabela densa, ordenação client-side default `% da própria votação desc`, Metropolitano decomposto em Salvador 19 zonas × Demais RMS, linha→`/campanha/municipios?region=<TI>`). Somas/razões apenas (salvaguarda MAUP); sem migration/collection. Primeira fatia de E12. Plano: [tabela-ti-inicio.md](plans/tabela-ti-inicio.md).
|
||||
- **C12 (2026-07-24)** — registro-fundação: versions nativas em `votePledge` com baseline; sinais tipados em `municipalityUpdate`; origem do `actionPlan`; demandas vinculadas com custo derivado e criação múltipla atômica; `allocationDecision` ex-ante imutável. Migration `20260724_180000_add_campaign_foundation_records`; testes int + E2E. Plano: [registro-fundacao.md](plans/registro-fundacao.md).
|
||||
- **E8 (2026-07-24)** — conta da cadeira: artefato TSE commitado estendido para v2 (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022` presidente/governador T1, via `pnpm build:election-aggregates`); curadoria `src/lib/campoParties.ts` (ano→siglas do campo, validada contra `electionPartySpectrum`); global `campaignGoals` (meta estadual 150 mil + margem, access staff-read/coordinator-write, migration `20260724_180000_add_campaign_goals_global`); `municipalityPotential.ts` (válidos projetados, teto do campo, captura, share intracampo, roll-off 2022-only, decomposição proporcional da meta + sanity por TI) e `goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = Σ pledges`, nunca a expectativa da mesa); encaixado no dashboard, na visão geral e na lista de municípios (nova coluna "Cobertura da meta"), e num novo card "Conta da cadeira" no detalhe. Plano: [conta-da-cadeira.md](plans/conta-da-cadeira.md). Desbloqueia **E9**, **E10**, **E12**, **E13** (e melhora **B13**/**E16**).
|
||||
- **E8 (2026-07-24)** — conta da cadeira: artefato TSE commitado estendido para v2 (`campoFederalVotesByYear`, `federalTallyByYear`, `majoritarian2022` presidente/governador T1, via `pnpm build:election-aggregates`); curadoria `src/lib/campoParties.ts` (ano→siglas do campo, validada contra `electionPartySpectrum`); global `campaignGoals` (meta estadual 150 mil + margem, access staff-read/coordinator-write, migration `20260724_180000_add_campaign_goals_global`); `municipalityPotential.ts` (válidos projetados, teto do campo, captura, share intracampo, roll-off 2022-only, meta sugerida + sanity por TI) e `goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = Σ pledges`, nunca a expectativa da mesa); encaixado no dashboard, na visão geral e na lista de municípios (nova coluna "Cobertura da meta"), e num novo card "Conta da cadeira" no detalhe. Plano: [conta-da-cadeira.md](plans/conta-da-cadeira.md). Desbloqueia **E9**, **E10**, **E12**, **E13** (e melhora **B13**/**E16**). _(A fórmula da meta sugerida foi revista no mesmo dia, durante o E9 — ver abaixo.)_
|
||||
- **E9 (2026-07-24)** — fila de alocação na própria lista de municípios (sem rota nova, sem coluna nova, sem migration). Corrigiu antes a meta sintética do E8, que repartia os 150 mil proporcionalmente **só ao teto do campo** e por isso dava meta 2.911 a Vitória da Conquista (5.005 votos em 2022) e 813 a Campo Formoso (47) — ordenar por déficit sobre ela colocaria desertos no topo. `deriveSuggestedGoalsByScenario` (substitui `decomposeStateGoal`) ancora a meta na votação própria de 2022 por cenário: pessimista = base×(1−`margin`), central = base, otimista = base×(`stateGoal` ÷ Σ base), com clamp; `goalCoverage` passou a receber `SuggestedGoalByScenario`. Na fila: `lastPledgeAt` no agregado de pledges, `lastSignalAt` = máx(`lastUpdateAt`, pledge) no view model, sort keys `deficit` (**novo default do staff**) e `frescor` (cenário `central` fixo), frescor dobrado na coluna "Última atualização" (frio ≥ 21 dias), badge "sem responsável" em prioridade alta sem assessor, "coluna da vergonha" no detalhe da métrica de assessoria (`?priority=alta&coverage=sem_assessor`) e copy Praças→Municípios. Cortes: votos em jogo → **B13**, LQ/captura → **E10**, coluna dedicada de déficit → desnecessária. Plano: [fila-de-alocacao.md](plans/fila-de-alocacao.md). Desbloqueia **E11**.
|
||||
- **E16 (2026-07-25)** — dossiê do município (pré-agenda, pedido O6): aba `?tab=dossie` staff-only no detalhe compondo os loaders existentes (`municipalityDossierData.ts` + `MunicipalityDossier.tsx`) — capa com data de geração, série TSE/rank (A11), conta da cadeira (E8), rede de lideranças com frescor, conjuntura/dobradinhas/encaminhamentos, agenda (`actionPlan`), sinais recentes (C12), perfil IBGE (A8 — reentrada do artefato, caveat para zonas); caps 8/5/3+2 com "ver tudo"; campo staff-only `municipality.budgetNotes` ("Emendas aportadas", G11 manual-first, migration `20260725_022155`); **visão print** via CSS (chrome oculto, shells destravados, tipografia A4 — 1–2 páginas). Plano: [dossie-municipio.md](plans/dossie-municipio.md).
|
||||
|
||||
## Próximos — Campanha (`/campanha`)
|
||||
|
|
@ -58,11 +59,10 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
|
|||
|
||||
| ID | Fatia | Plano | Entrega essencial | Classe | Appetite | Janela | Depende de |
|
||||
| --- | ---------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | --------- | ------------------------------ |
|
||||
| E9 | Fila de alocação | [detalhes](plans/fila-de-alocacao.md) | Lista de decisão (7 colunas + % da própria votação — absorve A11), ordenação por déficit descoberto (default do staff quando houver metas — registro no fluxo de poder) + risco por frescor, "coluna da vergonha" | B | ~1,5d | 2 | E8 ✓ |
|
||||
| E10 | Classificação territorial relativa | [detalhes](plans/classificacao-territorial-relativa.md) | Âncoras relativas (LQ / share da cadeira marginal), multi-eixo; substitui 35/20/10 para DF | B | ~1d | 3 | E8 ✓ |
|
||||
| B13 | Escala relativa no mapa | [detalhes](plans/escala-relativa-mapa.md) | Quantis do candidato (default) + LQ + rank; símbolo proporcional por votos em jogo; % válidos mantido | B | ~2d | 3 | E8 ✓, E10 |
|
||||
| E14 | Níveis de envolvimento N0–N4 | [detalhes](plans/niveis-de-envolvimento.md) | `engagementLevel` com histerese e sinais de reversão; staff-only (vocabulário duplo) | B | ~1d | 3 | C12 |
|
||||
| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9, E10, C12 |
|
||||
| E11 | Motor de sugestões v1 | [detalhes](plans/motor-de-sugestoes.md) | 8 padrões (P1/P2/P3/P5/P6/P7/K-A/K-B), menu com estatuto, aceitar/descartar → `allocationDecision`, triagem 1–5 | C | ~3d | 3 | E8 ✓, E9 ✓, E10, C12 |
|
||||
| E12 | Camada TI | [detalhes](plans/camada-territorios-identidade.md) | Rollups com salvaguardas MAUP, benchmark intra-TI (gatilhos T no motor = fase 2 de E11) | B | ~1,5d | 3 | E8 ✓ |
|
||||
| E13 | Planejador de presença/giros | [detalhes](plans/planejador-de-giros.md) | Elegibilidade (5 condições ✓/—), fases construção/consolidação/ativação, compositor de giro, visita pedida×justificada | C | ~1,5d | 3 | E8 ✓, C12 |
|
||||
| E15 | Backtest pós-eleição | [detalhes](plans/backtest-pos-eleicao.md) | Pledge (trajetória) vs. resultado por zona; calibração de limiares | A | ~1d | pós-04/10 | C12, TSE 2026 |
|
||||
|
|
@ -135,7 +135,7 @@ flowchart TD
|
|||
|
||||
subgraph Intel["Inteligência de campanha"]
|
||||
E8i["E8 ✓ Conta da cadeira"]
|
||||
E9i["E9 Fila de alocação"]
|
||||
E9i["E9 ✓ Fila de alocação"]
|
||||
C12i["C12 ✓ Registro-fundação"]
|
||||
E16i["E16 ✓ Dossiê do município"]
|
||||
E10i["E10 Classificação relativa"]
|
||||
|
|
@ -179,13 +179,13 @@ flowchart TD
|
|||
B8F2 -.ZE Salvador.-> B14n
|
||||
```
|
||||
|
||||
Paralelizáveis agora: **E9** (E8 ✓ entregue), **E10**/**E12** (E8 ✓ desbloqueou ambos), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), **B19** (gerenciar assessores — onboarding sem `/admin`; sem deps duras), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9).
|
||||
Paralelizáveis agora: **E10**/**E12** (E8 ✓ desbloqueou ambos), **E11** (E9 ✓ entregue; ainda espera E10), **B14** (atalho geo no Início; soft B8 F2 só para ZE Salvador), **B16** (filtros no header; soft B15 ✓ já entregue), **B17** (seletor de colunas; soft B15/B16 — pousa na barra atual ou slim), **B18** (filtros salvos + submenu Municípios; soft B16/B17 — botão na barra), **B19** (gerenciar assessores — onboarding sem `/admin`; sem deps duras), fill-ins (ícone de prioridade na lista, Cenário junto aos filtros — encaixa na barra slim de B16 —, O0+, RS+). **D3** só após smoke + folga e só se o fluxo sede-digita (C12 ✓) não absorver os deltas do ZAP (não compete com E9).
|
||||
|
||||
### Sequência por janela (só pendentes)
|
||||
|
||||
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + default `sort=votos`) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras); **B16** (filtros no header) / **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9** pode começar em paralelo; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
|
||||
**Janela 1 — agora → 05/08 (convenções):** smoke pós-deploy em produção + onboarding do time (Onda 0 §2/§4); **E4R ✓** em código (aplicar `pnpm db:seed:projecao` em produção após smoke); **E8 ✓** conta da cadeira entregue; **E17 ✓** tabela TI no Início; **B15 ✓** (sort por coluna) e **A11 ✓** (posição em votos + `sort=votos`; o default do staff passou a `deficit` no E9 ✓) entregues 2026-07-24; **E18 ✓** documentação de conceitos (`/campanha/conceitos`, v1 só E8) entregue 2026-07-24; **B19** gerenciar assessores (caminho do onboarding sem `/admin` — criar/ativar contas e carteiras); **B16** (filtros no header) / **B17** (seletor de colunas) / **B18** (filtros salvos + atalho sidebar) se folga de UX da lista no onboarding; gate de adoção: sinal Little Hire — ≥1 update espontâneo até 30/07, cobrança da tabela dispensada (planilhas já em `docs/sheets/`) — acompanhamento em [IMPROVE-APP-PLAN.md](IMPROVE-APP-PLAN.md); **R6** critique/polish; **E9 ✓** entregue no mesmo dia; **B14** (atalho geo) se sobrar folga de campo no onboarding; Onda 0 jurídica em paralelo (externa).
|
||||
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; **E9** fila de alocação (E8 ✓ e **C12 ✓** já entregues); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga.
|
||||
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga.
|
||||
|
||||
**Janela 3 — 16/08 → set:** A6 dobradinha (pós-TSE 15/08); **E10** classificação relativa → **B13** escala relativa no mapa; **E14** níveis N0–N4; **E11** motor de sugestões v1; **E12** camada TI; **E13** planejador de giros; **B8 F2** polígonos das zonas de Salvador; débitos sobreviventes. Se houver folga **e** os deltas do ZAP não estiverem sendo absorvidos pelo fluxo sede-digita (C12): começar **D3**.
|
||||
|
||||
|
|
@ -193,9 +193,9 @@ Paralelizáveis agora: **E9** (E8 ✓ entregue), **E10**/**E12** (E8 ✓ desbloq
|
|||
|
||||
### Cortes seguros / não cortáveis
|
||||
|
||||
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); **E9** (a fila de alocação é o mínimo de "inteligência, não planilha"); **B19** gerenciar assessores (onboarding Onda 0 §4 — CG/candidato não entram em `/admin`; sem esta UI o time só nasce via seed ou admin Payload).
|
||||
**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); **B19** gerenciar assessores (onboarding Onda 0 §4 — CG/candidato não entram em `/admin`; sem esta UI o time só nasce via seed ou admin Payload).
|
||||
|
||||
**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter fila E9 sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B17** seletor de colunas (tabela completa continua; scroll horizontal); **B16** filtros no header (barra de filtros atual continua a funcionar); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); débitos/fill-ins.
|
||||
**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B17** seletor de colunas (tabela completa continua; scroll horizontal); **B16** filtros no header (barra de filtros atual continua a funcionar); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); débitos/fill-ins.
|
||||
|
||||
## Bloqueadores atuais
|
||||
|
||||
|
|
|
|||
|
|
@ -90,9 +90,18 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
|
|||
/>
|
||||
)
|
||||
|
||||
// E9 coluna da vergonha: same scope, filtered down to priority municipalities
|
||||
// with nobody answering for them. `null` when that is already the filter.
|
||||
const shameHref =
|
||||
state.priority === 'alta' && state.coverage === 'sem_assessor'
|
||||
? null
|
||||
: buildMunicipalityListHref({ ...state, priority: 'alta', coverage: 'sem_assessor' }, 1)
|
||||
|
||||
const listBody = listMunicipalities.length ? (
|
||||
<>
|
||||
{isStaffView && overview ? <MunicipalityListOverview view={overview} /> : null}
|
||||
{isStaffView && overview ? (
|
||||
<MunicipalityListOverview view={overview} shameHref={shameHref} />
|
||||
) : null}
|
||||
<MunicipalityList
|
||||
municipalities={listMunicipalities}
|
||||
advisorNamesById={advisorNamesById}
|
||||
|
|
|
|||
|
|
@ -146,8 +146,8 @@ export const MunicipalityGoalAccountCard = ({
|
|||
<CampaignInfoHint label="Sobre a conta da cadeira">
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>
|
||||
<strong>Meta</strong> é a estimativa da mesa; sem estimativa, usa a meta sugerida
|
||||
(decomposição proporcional da meta estadual pelo teto do campo projetado).
|
||||
<strong>Meta</strong> é a estimativa da mesa; sem estimativa, usa a meta sugerida —
|
||||
a votação do próprio Jorge Solla aqui em 2022 (cenário média).
|
||||
</p>
|
||||
<p>
|
||||
<strong>Comprometido</strong> é só a soma das declarações de lideranças — nunca a
|
||||
|
|
@ -181,7 +181,7 @@ export const MunicipalityGoalAccountCard = ({
|
|||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Meta usada na conta: {formatElectionNumber(goalCoverage.goal)} (
|
||||
{usesMesaEstimate ? 'estimativa da mesa' : 'meta sugerida, decomposição da meta estadual'})
|
||||
{usesMesaEstimate ? 'estimativa da mesa' : 'meta sugerida: votação de 2022 aqui'})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -274,10 +274,10 @@ export const MunicipalityGoalAccountCard = ({
|
|||
}
|
||||
/>
|
||||
</dl>
|
||||
{suggestedGoal <= 0 ? (
|
||||
{!usesMesaEstimate && suggestedGoal <= 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sem teto do campo projetado para decompor uma meta sugerida — cadastre a estimativa da
|
||||
mesa manualmente.
|
||||
Sem votação própria em 2022 aqui, então não há meta sugerida — é praça de expansão:
|
||||
cadastre a estimativa da mesa manualmente se decidir investir.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -29,8 +29,13 @@ import {
|
|||
formatMunicipalityConcentrationHint,
|
||||
formatMunicipalityGeographyLabel,
|
||||
formatMunicipalityListSortSummary,
|
||||
formatMunicipalitySignalAgeLabel,
|
||||
isMunicipalitySignalCold,
|
||||
MUNICIPALITY_COLD_SIGNAL_DAYS,
|
||||
municipalityKindLabels,
|
||||
municipalityListCoverageLabels,
|
||||
municipalityPriorityLabels,
|
||||
municipalitySignalAgeInDays,
|
||||
resolveMunicipalityListSort,
|
||||
type MunicipalityListState,
|
||||
} from '@/utilities/municipalityUi'
|
||||
|
|
@ -39,6 +44,10 @@ import type {
|
|||
MunicipalityAdvisorSummary,
|
||||
MunicipalityListViewModel,
|
||||
} from '@/utilities/municipalityViewModels'
|
||||
import {
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
voteEstimateScenarioLabels,
|
||||
} from '@/utilities/voteEstimate'
|
||||
import { toMunicipalityPledgeCoverageView } from '@/utilities/votePledgeData'
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('pt-BR')
|
||||
|
|
@ -85,6 +94,80 @@ const VotePositionReadout = ({
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* E9 frescor: how long since anybody recorded anything here (staff update or
|
||||
* leadership pledge).
|
||||
*
|
||||
* Cold reads in the warning amber, NOT destructive: early in the campaign
|
||||
* most municípios are past the 21-day threshold, and painting them all red
|
||||
* would drown the one state that really is an error in this list — a priority
|
||||
* município with nobody answering for it. Same reason it stays text with an
|
||||
* icon instead of a third badge: the row already carries the priority and
|
||||
* assessoria pills.
|
||||
*/
|
||||
const SignalAgeReadout = ({
|
||||
lastSignalAt,
|
||||
layout,
|
||||
}: {
|
||||
lastSignalAt: string | null
|
||||
layout: 'table' | 'card'
|
||||
}) => {
|
||||
const ageInDays = municipalitySignalAgeInDays(lastSignalAt)
|
||||
const isCold = isMunicipalitySignalCold(ageInDays)
|
||||
const ageLabel = formatMunicipalitySignalAgeLabel(ageInDays)
|
||||
const dateLabel = lastSignalAt ? dateFormatter.format(new Date(lastSignalAt)) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-signal={isCold ? 'cold' : 'fresh'}
|
||||
className={cn('flex flex-col gap-0.5', layout === 'card' && 'text-sm')}
|
||||
title={
|
||||
isCold
|
||||
? `Sem registro novo há ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias ou mais — atualização da equipe ou declaração de liderança.`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 tabular-nums',
|
||||
isCold ? 'font-medium text-estimate-pending-foreground' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{isCold ? <CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" /> : null}
|
||||
{ageLabel}
|
||||
</span>
|
||||
{dateLabel ? (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{dateLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* E9: a priority município with nobody answering for it is the queue's
|
||||
* loudest row, so it reads "Sem responsável" in destructive — the same words
|
||||
* the overview's coluna da vergonha uses to count them. Non-priority ones
|
||||
* keep the softer pending tone: they are a gap, not a fire.
|
||||
*/
|
||||
const AdvisorStatusBadge = ({ municipality }: { municipality: MunicipalityListViewModel }) => {
|
||||
if (municipality.advisorIDs.length > 0) {
|
||||
return (
|
||||
<Badge variant="estimate-confirmed">
|
||||
<CircleCheckIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Coberta
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const isPriority = municipality.priority === 'alta'
|
||||
return (
|
||||
<Badge variant={isPriority ? 'destructive' : 'estimate-pending'}>
|
||||
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{isPriority ? 'Sem responsável' : municipalityListCoverageLabels.sem_assessor}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const advisorEntries = (
|
||||
municipality: MunicipalityListViewModel,
|
||||
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>,
|
||||
|
|
@ -112,6 +195,10 @@ export const MunicipalityList = ({
|
|||
const { sort: activeSort, dir: activeDir } = resolveMunicipalityListSort(state)
|
||||
const sortSummary = formatMunicipalityListSortSummary(activeSort, activeDir)
|
||||
const concentrationHint = formatMunicipalityConcentrationHint()
|
||||
const signalHint = `Última atualização da equipe ou declaração de liderança, o que for mais recente. Fica destacado a partir de ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias sem registro.`
|
||||
// The scenario picker is client state, so the server can only order by one
|
||||
// scenario — named here so the ordering never looks arbitrary.
|
||||
const deficitHint = `Ordena pelo que falta para a meta (meta − comprometido) no cenário ${voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO]}, independente do cenário selecionado acima.`
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -169,7 +256,13 @@ export const MunicipalityList = ({
|
|||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Último sinal</dt>
|
||||
<dd>
|
||||
<SignalAgeReadout lastSignalAt={municipality.lastSignalAt} layout="card" />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Assessoria</dt>
|
||||
<dd>
|
||||
{isCoordinator ? (
|
||||
|
|
@ -184,7 +277,7 @@ export const MunicipalityList = ({
|
|||
) : names.length ? (
|
||||
names.join(', ')
|
||||
) : (
|
||||
'Sem assessor'
|
||||
<AdvisorStatusBadge municipality={municipality} />
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
|
@ -229,13 +322,23 @@ export const MunicipalityList = ({
|
|||
<MunicipalitySortableHead state={state} sortKey="expectedVotes" align="center">
|
||||
Votos estimados
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
|
||||
Última atualização
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="frescor"
|
||||
tooltip={signalHint}
|
||||
>
|
||||
Último sinal
|
||||
</MunicipalitySortableHead>
|
||||
<MunicipalitySortableHead state={state} sortKey="coverage">
|
||||
Assessoria
|
||||
</MunicipalitySortableHead>
|
||||
<TableHead>Cobertura da meta</TableHead>
|
||||
<MunicipalitySortableHead
|
||||
state={state}
|
||||
sortKey="deficit"
|
||||
tooltip={deficitHint}
|
||||
>
|
||||
Cobertura da meta
|
||||
</MunicipalitySortableHead>
|
||||
</>
|
||||
) : (
|
||||
<MunicipalitySortableHead state={state} sortKey="lastUpdateAt">
|
||||
|
|
@ -246,8 +349,6 @@ export const MunicipalityList = ({
|
|||
</TableHeader>
|
||||
<TableBody>
|
||||
{municipalities.map((municipality) => {
|
||||
const hasAdvisor = municipality.advisorIDs.length > 0
|
||||
|
||||
return (
|
||||
<TableRow key={municipality.id}>
|
||||
<TableCell className="max-w-52 whitespace-normal">
|
||||
|
|
@ -314,19 +415,13 @@ export const MunicipalityList = ({
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{municipality.lastUpdateAt
|
||||
? dateFormatter.format(new Date(municipality.lastUpdateAt))
|
||||
: 'Sem atualização'}
|
||||
<SignalAgeReadout
|
||||
lastSignalAt={municipality.lastSignalAt}
|
||||
layout="table"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={hasAdvisor ? 'estimate-confirmed' : 'estimate-pending'}>
|
||||
{hasAdvisor ? (
|
||||
<CircleCheckIcon data-icon="inline-start" aria-hidden="true" />
|
||||
) : (
|
||||
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
|
||||
)}
|
||||
{hasAdvisor ? 'Coberta' : 'Sem assessor'}
|
||||
</Badge>
|
||||
<AdvisorStatusBadge municipality={municipality} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MunicipalityListGoalCoverageCell
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import {
|
|||
formatGoalCoverageRatioLabel,
|
||||
type MunicipalityGoalCoverage,
|
||||
} from '@/utilities/goalCoverage'
|
||||
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, type VoteEstimateScenario } from '@/utilities/voteEstimate'
|
||||
import {
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
voteEstimateScenarioLabels,
|
||||
type VoteEstimateScenario,
|
||||
} from '@/utilities/voteEstimate'
|
||||
|
||||
/**
|
||||
* E8 "conta da cadeira" list cell: reads the shared scenario picker (same
|
||||
|
|
@ -31,16 +35,27 @@ export const MunicipalityListGoalCoverageCell = ({
|
|||
const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
|
||||
const coverage = coverageByScenario[activeScenario]
|
||||
const deficitLabel = formatGoalCoverageDeficitLabel(coverage)
|
||||
// Names the scenario the figures belong to: the header sorts by `central`
|
||||
// regardless of this picker (E9), so an unnamed number here would be
|
||||
// ambiguous the moment the two disagree.
|
||||
const scenarioLabel = voteEstimateScenarioLabels[activeScenario]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium tabular-nums">{formatGoalCoverageRatioLabel(coverage)}</span>
|
||||
<span className="font-medium tabular-nums" title={`Cenário ${scenarioLabel}`}>
|
||||
{formatGoalCoverageRatioLabel(coverage)}
|
||||
</span>
|
||||
{layout === 'compact' ? (
|
||||
<span className="text-xs text-muted-foreground tabular-nums" title={deficitLabel}>
|
||||
<span
|
||||
className="text-xs text-muted-foreground tabular-nums"
|
||||
title={`${deficitLabel} (cenário ${scenarioLabel})`}
|
||||
>
|
||||
{formatGoalCoverageDeficitShortLabel(coverage)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{deficitLabel}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{deficitLabel} · cenário {scenarioLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
'use client'
|
||||
|
||||
import { CircleAlertIcon } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/CampaignListPending'
|
||||
import { CampaignMetricStrip } from '@/components/campaign/CampaignMetricStrip'
|
||||
import { campaignPrioritySurfaceClassName } from '@/components/campaign/CampaignPageShell'
|
||||
import { useMunicipalityEstimateScenario } from '@/components/campaign/MunicipalityEstimateScenarioContext'
|
||||
|
|
@ -26,7 +28,52 @@ import {
|
|||
|
||||
const SCENARIO_FLASH_MS = 420
|
||||
|
||||
export const MunicipalityListOverview = ({ view }: { view: MunicipalityListOverviewData }) => {
|
||||
/**
|
||||
* E9 "coluna da vergonha": the assessoria ratio hides the only gap the
|
||||
* coordination can close today, so the priority municipalities with nobody
|
||||
* answering for them are named as their own number — and the number is the
|
||||
* link that filters the list down to exactly those rows (`shameHref` is
|
||||
* `null` when that filter is already applied, so the count doesn't pretend to
|
||||
* navigate somewhere new).
|
||||
*/
|
||||
const AssessoriaGapDetail = ({
|
||||
priorityWithoutAdvisorCount,
|
||||
shameHref,
|
||||
}: {
|
||||
priorityWithoutAdvisorCount: number
|
||||
shameHref: string | null
|
||||
}) => {
|
||||
if (priorityWithoutAdvisorCount === 0) {
|
||||
return <p className="text-xs text-muted-foreground">Nenhuma prioritária sem responsável</p>
|
||||
}
|
||||
|
||||
const label = `${formatElectionNumber(priorityWithoutAdvisorCount)} ${
|
||||
priorityWithoutAdvisorCount === 1 ? 'prioritária' : 'prioritárias'
|
||||
} sem responsável`
|
||||
|
||||
if (!shameHref) {
|
||||
return <p className="text-xs font-medium text-destructive tabular-nums">{label}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<CampaignTransitionAnchor
|
||||
href={shameHref}
|
||||
scroll={false}
|
||||
className="inline-flex min-h-11 items-center gap-1 text-xs font-medium text-destructive tabular-nums underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{label}
|
||||
</CampaignTransitionAnchor>
|
||||
)
|
||||
}
|
||||
|
||||
export const MunicipalityListOverview = ({
|
||||
view,
|
||||
shameHref,
|
||||
}: {
|
||||
view: MunicipalityListOverviewData
|
||||
shameHref: string | null
|
||||
}) => {
|
||||
const { scenario, setScenario } = useMunicipalityEstimateScenario()
|
||||
const [isFlashing, setIsFlashing] = useState(false)
|
||||
const skipFlashRef = useRef(true)
|
||||
|
|
@ -79,7 +126,7 @@ export const MunicipalityListOverview = ({ view }: { view: MunicipalityListOverv
|
|||
|
||||
return (
|
||||
<section
|
||||
aria-label="Visão geral das Praças filtradas"
|
||||
aria-label="Visão geral dos municípios filtrados"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'rounded-xl transition-[box-shadow,background-color] duration-300',
|
||||
|
|
@ -99,11 +146,11 @@ export const MunicipalityListOverview = ({ view }: { view: MunicipalityListOverv
|
|||
className="rounded-none ring-0"
|
||||
metrics={[
|
||||
{
|
||||
label: `${scenarioLabel} nas Praças filtradas`,
|
||||
label: `${scenarioLabel} nos municípios filtrados`,
|
||||
value: formatElectionNumber(activeTotal),
|
||||
detail: heroDetail,
|
||||
emphasize: true,
|
||||
valueAriaLabel: `${scenarioLabel} nas Praças filtradas: ${formatElectionNumber(activeTotal)}${
|
||||
valueAriaLabel: `${scenarioLabel} nos municípios filtrados: ${formatElectionNumber(activeTotal)}${
|
||||
endpointsLabel ? `; ${endpointsLabel}` : ''
|
||||
}${
|
||||
view.missingEstimateCount > 0
|
||||
|
|
@ -117,7 +164,13 @@ export const MunicipalityListOverview = ({ view }: { view: MunicipalityListOverv
|
|||
},
|
||||
{
|
||||
label: 'Cobertura de assessoria',
|
||||
value: `${view.withAdvisorCount} de ${view.municipalityCount} Praças`,
|
||||
value: `${view.withAdvisorCount} de ${view.municipalityCount} municípios`,
|
||||
detail: (
|
||||
<AssessoriaGapDetail
|
||||
priorityWithoutAdvisorCount={view.priorityWithoutAdvisorCount}
|
||||
shameHref={shameHref}
|
||||
/>
|
||||
),
|
||||
progress:
|
||||
view.municipalityCount > 0
|
||||
? Math.round((view.withAdvisorCount / view.municipalityCount) * 100)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import type { GlobalConfig } from 'payload'
|
|||
const slug = 'campaignGoals'
|
||||
|
||||
/**
|
||||
* State-level campaign goal used by E8 "conta da cadeira" to decompose a
|
||||
* per-municipality suggested goal proportional to each municipality's
|
||||
* projected field ceiling (`municipalityPotential.ts`). Editable by staff
|
||||
* (piso do CG, sessão 2026-07-23: 150.000) rather than derived automatically —
|
||||
* decomposition reads this value, it never writes back to it.
|
||||
* State-level campaign goal used by E8 "conta da cadeira" to derive the
|
||||
* per-municipality suggested goal (`municipalityPotential.ts`'s
|
||||
* `deriveSuggestedGoalsByScenario`, anchored on the candidate's own 2022 vote:
|
||||
* `stateGoal` sets the optimistic scenario's growth factor, `margin` the
|
||||
* pessimistic haircut). Editable by staff (piso do CG, sessão 2026-07-23:
|
||||
* 150.000) rather than derived automatically — the derivation reads these
|
||||
* values, it never writes back to them.
|
||||
*
|
||||
* No `afterChange` revalidation hook: `/campanha` is dynamic with
|
||||
* authentication on every request (no ISR/ISR tag caches this global), and
|
||||
|
|
@ -40,7 +42,7 @@ export const CampaignGoals: GlobalConfig = {
|
|||
min: 0,
|
||||
admin: {
|
||||
description:
|
||||
'Piso decidido pela coordenação geral. A meta por município (E8) é decomposta proporcionalmente ao teto do campo projetado a partir deste valor.',
|
||||
'Piso decidido pela coordenação geral. Define o cenário OTIMISTA da meta sugerida por município: a votação de 2022 de cada município é multiplicada por (meta estadual ÷ votação estadual de 2022), então a soma das metas otimistas fecha exatamente neste valor.',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -49,7 +51,8 @@ export const CampaignGoals: GlobalConfig = {
|
|||
label: 'Margem (%)',
|
||||
min: 0,
|
||||
admin: {
|
||||
description: 'Margem de segurança sobre a meta estadual, para leitura da mesa (não altera a decomposição).',
|
||||
description:
|
||||
'Corte do cenário PESSIMISTA da meta sugerida: quanto da votação de 2022 a campanha admite perder (ex.: 10 = meta pessimista 10% abaixo de 2022). Vazio usa 10%.',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2057,11 +2057,11 @@ export interface PrivacyPolicy {
|
|||
export interface CampaignGoal {
|
||||
id: number;
|
||||
/**
|
||||
* Piso decidido pela coordenação geral. A meta por município (E8) é decomposta proporcionalmente ao teto do campo projetado a partir deste valor.
|
||||
* Piso decidido pela coordenação geral. Define o cenário OTIMISTA da meta sugerida por município: a votação de 2022 de cada município é multiplicada por (meta estadual ÷ votação estadual de 2022), então a soma das metas otimistas fecha exatamente neste valor.
|
||||
*/
|
||||
stateGoal: number;
|
||||
/**
|
||||
* Margem de segurança sobre a meta estadual, para leitura da mesa (não altera a decomposição).
|
||||
* Corte do cenário PESSIMISTA da meta sugerida: quanto da votação de 2022 a campanha admite perder (ex.: 10 = meta pessimista 10% abaixo de 2022). Vazio usa 10%.
|
||||
*/
|
||||
margin?: number | null;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ type CampaignGoalsReader = CampaignUser | User
|
|||
|
||||
/**
|
||||
* Request-scoped read of the `campaignGoals` global (E8 "conta da cadeira" —
|
||||
* state-level vote goal + margin, decomposed into a per-municipality
|
||||
* suggested goal by `municipalityPotential.ts`). Degrau 1 da caching ladder
|
||||
* state-level vote goal + margin, turned into a per-municipality suggested
|
||||
* goal by `municipalityPotential.ts`). Degrau 1 da caching ladder
|
||||
* (React `cache()` per-request dedup): the global changes rarely and every
|
||||
* page that shows coverage needs the same value once per request.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -116,6 +116,21 @@ const bahiaDateTimeDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
|
|||
export const formatBahiaDateTimeLabel = (iso: string): string =>
|
||||
bahiaDateTimeDisplayFormatter.format(new Date(iso)).replace(', ', ' às ')
|
||||
|
||||
/**
|
||||
* Latest of two ISO timestamps, ignoring nulls. String comparison is only
|
||||
* sound because every writer here produces fixed-width UTC — Payload's own
|
||||
* `createdAt`/`updatedAt` and our `new Date().toISOString()` hooks. An offset
|
||||
* form (`-03:00`) or variable precision would break it.
|
||||
*/
|
||||
export const latestIsoTimestamp = (
|
||||
left: string | null | undefined,
|
||||
right: string | null | undefined,
|
||||
): string | null => {
|
||||
if (!left) return right ?? null
|
||||
if (!right) return left
|
||||
return right > left ? right : left
|
||||
}
|
||||
|
||||
export const getBahiaWeekRange = (now: Date): { start: Date; end: Date } => {
|
||||
const local = getZonedParts(now)
|
||||
const localMidnight = { ...local, hour: 0, minute: 0, second: 0 }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { formatElectionNumber } from '@/lib/electionInsights'
|
||||
import type { SuggestedGoalByScenario } from '@/utilities/municipalityPotential'
|
||||
import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
|
||||
import {
|
||||
getVoteEstimateForScenario,
|
||||
|
|
@ -11,9 +12,12 @@ import {
|
|||
* E8 "conta da cadeira" — coverage of the goal by auditable pledges.
|
||||
*
|
||||
* Semantics locked this session (see the E8 plan's audit finding #2):
|
||||
* - **meta** = `expectedVotes[cenário] ?? suggestedGoal` — the mesa's own
|
||||
* expectation when set (`Municipality.expectedVotes`), falling back to the
|
||||
* goal decomposed from the state goal (`municipalityPotential.ts`).
|
||||
* - **meta** = `expectedVotes[cenário] ?? suggestedGoal[cenário]` — the mesa's
|
||||
* own expectation when set (`Municipality.expectedVotes`), falling back to
|
||||
* the suggested goal for the SAME scenario (`municipalityPotential.ts`'s
|
||||
* `deriveSuggestedGoalsByScenario`, anchored on the candidate's own 2022
|
||||
* vote). Both sides of the fallback are now per-scenario, so switching the
|
||||
* list's scenario picker moves the goal as well as the commitment.
|
||||
* - **comprometido** = `aggregate.effectiveByScenario[cenário]` — the pledge
|
||||
* aggregate ONLY (`estimated[S] ?? declared`, summed over `votePledge`
|
||||
* rows). It is deliberately **not** `resolveMunicipalityStaffVoteTotal`,
|
||||
|
|
@ -54,11 +58,12 @@ export const createEmptyGoalCoverageByScenario = (): Record<
|
|||
/** Coverage for one municipality, one scenario. */
|
||||
export const computeGoalCoverage = (
|
||||
expectedVotes: VoteEstimateScenarioFields | null | undefined,
|
||||
suggestedGoal: number,
|
||||
suggestedGoalByScenario: SuggestedGoalByScenario,
|
||||
pledgeAggregate: MunicipalityPledgeAggregate,
|
||||
scenario: VoteEstimateScenario,
|
||||
): MunicipalityGoalCoverage => {
|
||||
const goal = getVoteEstimateForScenario(expectedVotes, scenario) ?? suggestedGoal
|
||||
const goal =
|
||||
getVoteEstimateForScenario(expectedVotes, scenario) ?? suggestedGoalByScenario[scenario]
|
||||
const committed = pledgeAggregate.effectiveByScenario[scenario]
|
||||
const coverageRatio = goal > 0 ? committed / goal : null
|
||||
const deficit = goal - committed
|
||||
|
|
@ -68,14 +73,14 @@ export const computeGoalCoverage = (
|
|||
/** Coverage for one municipality, all three scenarios (mirrors `staffVoteTotalByScenario`). */
|
||||
export const computeGoalCoverageByScenario = (
|
||||
expectedVotes: VoteEstimateScenarioFields | null | undefined,
|
||||
suggestedGoal: number,
|
||||
suggestedGoalByScenario: SuggestedGoalByScenario,
|
||||
pledgeAggregate: MunicipalityPledgeAggregate,
|
||||
): Record<VoteEstimateScenario, MunicipalityGoalCoverage> => {
|
||||
const coverageByScenario = {} as Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
|
||||
coverageByScenario[scenario] = computeGoalCoverage(
|
||||
expectedVotes,
|
||||
suggestedGoal,
|
||||
suggestedGoalByScenario,
|
||||
pledgeAggregate,
|
||||
scenario,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ import {
|
|||
} from '@/utilities/goalCoverage'
|
||||
import {
|
||||
computeMunicipalityPotential,
|
||||
computeStatewideGoalDecomposition,
|
||||
computeStatewideSuggestedGoals,
|
||||
type MunicipalityPotential,
|
||||
type SuggestedGoalByScenario,
|
||||
} from '@/utilities/municipalityPotential'
|
||||
import {
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
VOTE_ESTIMATE_SCENARIOS,
|
||||
zeroByVoteEstimateScenario,
|
||||
type VoteEstimateScenarioFields,
|
||||
type VoteEstimateScenario,
|
||||
} from '@/utilities/voteEstimate'
|
||||
|
|
@ -31,19 +33,19 @@ import {
|
|||
type GoalAccountReader = CampaignUser | User
|
||||
|
||||
/**
|
||||
* E8 "conta da cadeira" — request-scoped statewide goal decomposition
|
||||
* (`campaignGoals` global + `computeStatewideGoalDecomposition`), degrau 1 da
|
||||
* caching ladder. Every surface (dashboard, list overview, list rows,
|
||||
* detail card) needs the SAME decomposition — `suggestedGoal` for one
|
||||
* município is only meaningful relative to the full 435-slug ceiling total,
|
||||
* so this always runs over the whole catalog, never a filtered scope, and is
|
||||
* E8 "conta da cadeira" — request-scoped statewide suggested-goal ladder
|
||||
* (`campaignGoals` global + `computeStatewideSuggestedGoals`), degrau 1 da
|
||||
* caching ladder. Every surface (dashboard, list overview, list rows, detail
|
||||
* card) needs the SAME goals — the optimistic scenario's growth factor is
|
||||
* `stateGoal / Σ base` over the full 435-slug catalog, so this always runs
|
||||
* over the whole catalog, never a filtered scope, and is
|
||||
* `cache()`-deduplicated so dashboard + list + detail don't each recompute it
|
||||
* on requests that touch more than one.
|
||||
*/
|
||||
const loadGoalDecompositionCached = cache(
|
||||
const loadSuggestedGoalsCached = cache(
|
||||
async (payload: Payload, user: GoalAccountReader) => {
|
||||
const goals = await loadCampaignGoals(payload, user)
|
||||
return computeStatewideGoalDecomposition(goals)
|
||||
return computeStatewideSuggestedGoals(goals)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -70,7 +72,7 @@ export const loadMunicipalityGoalCoverageBundle = async (
|
|||
}>,
|
||||
pledgeAggregates: Map<number, MunicipalityPledgeAggregate>,
|
||||
): Promise<MunicipalityGoalCoverageBundle> => {
|
||||
const { suggestedGoalBySlug } = await loadGoalDecompositionCached(payload, user)
|
||||
const { suggestedGoalBySlug } = await loadSuggestedGoalsCached(payload, user)
|
||||
|
||||
const coverageByMunicipalityID = new Map<
|
||||
number,
|
||||
|
|
@ -79,10 +81,15 @@ export const loadMunicipalityGoalCoverageBundle = async (
|
|||
for (const municipality of municipalities) {
|
||||
const pledgeAggregate =
|
||||
pledgeAggregates.get(municipality.id) ?? emptyMunicipalityPledgeAggregate
|
||||
const suggestedGoal = suggestedGoalBySlug.get(municipality.slug) ?? 0
|
||||
const suggestedGoalByScenario =
|
||||
suggestedGoalBySlug.get(municipality.slug) ?? emptySuggestedGoalByScenario
|
||||
coverageByMunicipalityID.set(
|
||||
municipality.id,
|
||||
computeGoalCoverageByScenario(municipality.expectedVotes, suggestedGoal, pledgeAggregate),
|
||||
computeGoalCoverageByScenario(
|
||||
municipality.expectedVotes,
|
||||
suggestedGoalByScenario,
|
||||
pledgeAggregate,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +104,11 @@ export const loadMunicipalityGoalCoverageBundle = async (
|
|||
return { coverageByMunicipalityID, aggregateByScenario }
|
||||
}
|
||||
|
||||
/** Unknown slug (never in the catalog): zero goal in every scenario, never mutated. */
|
||||
const emptySuggestedGoalByScenario: Readonly<SuggestedGoalByScenario> = zeroByVoteEstimateScenario()
|
||||
|
||||
export type MunicipalityGoalAccount = {
|
||||
/** Fixed to the `central` scenario, like `goalCoverage` below. */
|
||||
suggestedGoal: number
|
||||
/** Fixed to the `central` scenario — the detail page has no scenario selector. */
|
||||
goalCoverage: MunicipalityGoalCoverage
|
||||
|
|
@ -115,16 +126,21 @@ export const loadMunicipalityGoalAccount = async (
|
|||
municipality: { slug: string; expectedVotes?: VoteEstimateScenarioFields | null },
|
||||
pledgeAggregate: MunicipalityPledgeAggregate,
|
||||
): Promise<MunicipalityGoalAccount> => {
|
||||
const { suggestedGoalBySlug, potentialBySlug } = await loadGoalDecompositionCached(payload, user)
|
||||
const suggestedGoal = suggestedGoalBySlug.get(municipality.slug) ?? 0
|
||||
const { suggestedGoalBySlug, potentialBySlug } = await loadSuggestedGoalsCached(payload, user)
|
||||
const suggestedGoalByScenario =
|
||||
suggestedGoalBySlug.get(municipality.slug) ?? emptySuggestedGoalByScenario
|
||||
const goalCoverage = computeGoalCoverage(
|
||||
municipality.expectedVotes,
|
||||
suggestedGoal,
|
||||
suggestedGoalByScenario,
|
||||
pledgeAggregate,
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
)
|
||||
const potential =
|
||||
potentialBySlug.get(municipality.slug) ?? computeMunicipalityPotential(municipality.slug)
|
||||
|
||||
return { suggestedGoal, goalCoverage, potential }
|
||||
return {
|
||||
suggestedGoal: suggestedGoalByScenario[DEFAULT_VOTE_ESTIMATE_SCENARIO],
|
||||
goalCoverage,
|
||||
potential,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
buildMunicipalityListWhere,
|
||||
municipalityPageSize,
|
||||
parseMunicipalityListParams,
|
||||
resolveMunicipalityLastSignalAt,
|
||||
resolveMunicipalityListSort,
|
||||
type MunicipalityListSearchParams,
|
||||
type MunicipalityListSortDirection,
|
||||
|
|
@ -50,6 +51,13 @@ export type MunicipalityListOverviewData = {
|
|||
pledgeCount: number
|
||||
missingEstimateCount: number
|
||||
withAdvisorCount: number
|
||||
/**
|
||||
* E9 "coluna da vergonha": priority municipalities in scope with nobody
|
||||
* answering for them — the one gap the coordination can close today, so it
|
||||
* gets named as a number and a filtered link instead of hiding inside the
|
||||
* assessoria ratio.
|
||||
*/
|
||||
priorityWithoutAdvisorCount: number
|
||||
/** E8 "conta da cadeira" — meta × comprometido do escopo filtrado, por cenário. */
|
||||
goalCoverageByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
}
|
||||
|
|
@ -92,11 +100,20 @@ const sortByNullableValue = (
|
|||
})
|
||||
}
|
||||
|
||||
type DerivedSortContext = {
|
||||
ranks: ReadonlyMap<string, MunicipalityVoteRankEntry>
|
||||
goalCoverageByMunicipalityID: ReadonlyMap<
|
||||
number,
|
||||
Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
>
|
||||
pledgeAggregates: ReadonlyMap<number, MunicipalityPledgeAggregate>
|
||||
}
|
||||
|
||||
const applyDerivedMunicipalitySort = (
|
||||
docs: Municipality[],
|
||||
sortKey: MunicipalityListSortKey,
|
||||
dir: MunicipalityListSortDirection,
|
||||
ranks: ReadonlyMap<string, MunicipalityVoteRankEntry>,
|
||||
{ ranks, goalCoverageByMunicipalityID, pledgeAggregates }: DerivedSortContext,
|
||||
): Municipality[] => {
|
||||
switch (sortKey) {
|
||||
case 'expectedVotes':
|
||||
|
|
@ -113,6 +130,30 @@ const applyDerivedMunicipalitySort = (
|
|||
if (byVotes !== 0) return byVotes
|
||||
return municipalityNameCompare(left, right)
|
||||
})
|
||||
case 'deficit':
|
||||
// Fixed to `central`: the scenario picker is client-side state, so the
|
||||
// server has no scenario to sort by (moving it to the URL is the
|
||||
// "Cenário junto aos filtros" fill-in). A município with no goal at all
|
||||
// has no deficit to rank — `null` sends it to the end either way.
|
||||
return sortByNullableValue(docs, dir, (municipality) => {
|
||||
const coverage = goalCoverageByMunicipalityID.get(municipality.id)?.central
|
||||
return coverage && coverage.goal > 0 ? coverage.deficit : null
|
||||
})
|
||||
case 'frescor': {
|
||||
// One clock read for the whole sort: the accessor runs per comparison,
|
||||
// and a "now" that drifts mid-sort is not a consistent ordering key.
|
||||
const now = Date.now()
|
||||
// Descending = coldest first, so "never had a signal" must outrank the
|
||||
// oldest date instead of sinking as a null: age is +Infinity there.
|
||||
return sortByNullableValue(docs, dir, (municipality) => {
|
||||
const lastSignalAt = resolveMunicipalityLastSignalAt(
|
||||
municipality.lastUpdateAt ?? null,
|
||||
pledgeAggregates.get(municipality.id)?.lastPledgeAt ?? null,
|
||||
)
|
||||
if (!lastSignalAt) return Number.POSITIVE_INFINITY
|
||||
return now - new Date(lastSignalAt).getTime()
|
||||
})
|
||||
}
|
||||
default:
|
||||
return docs
|
||||
}
|
||||
|
|
@ -198,6 +239,10 @@ export const loadMunicipalityListPageBundle = async (
|
|||
withAdvisorCount: staffScope.municipalities.filter(
|
||||
(municipality) => (municipality.advisors ?? []).length > 0,
|
||||
).length,
|
||||
priorityWithoutAdvisorCount: staffScope.municipalities.filter(
|
||||
(municipality) =>
|
||||
municipality.priority === 'alta' && (municipality.advisors ?? []).length === 0,
|
||||
).length,
|
||||
goalCoverageByScenario: goalCoverageBundle.aggregateByScenario,
|
||||
}
|
||||
}
|
||||
|
|
@ -216,7 +261,11 @@ export const loadMunicipalityListPageBundle = async (
|
|||
listResult.docs as Municipality[],
|
||||
sortKey,
|
||||
sortDir,
|
||||
ranks,
|
||||
{
|
||||
ranks,
|
||||
goalCoverageByMunicipalityID,
|
||||
pledgeAggregates,
|
||||
},
|
||||
)
|
||||
totalDocs = allDocs.length
|
||||
totalPages = Math.max(1, Math.ceil(totalDocs / municipalityPageSize))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import type { CampaignGoal } from '@/payload-types'
|
||||
import type { VoteEstimateScenario } from '@/utilities/voteEstimate'
|
||||
|
||||
/**
|
||||
* E8 "conta da cadeira" — derived electoral potential per municipality, built
|
||||
|
|
@ -20,6 +21,10 @@ import type { CampaignGoal } from '@/payload-types'
|
|||
* the E8 plan's "rabbit holes vigiados": projection turning into a model).
|
||||
*/
|
||||
|
||||
/** Own nominal votes in one year — 0 for a year the artifact doesn't cover. */
|
||||
const votesInYear = (baseline: MunicipalityFederalBaseline, year: number): number =>
|
||||
baseline.votesByYear[String(year)] ?? 0
|
||||
|
||||
/**
|
||||
* Projected 2026 valid votes: weighted average of 2014/2018/2022 federal T1
|
||||
* valid votes, weighting 2022 double (closer election, more predictive of
|
||||
|
|
@ -68,8 +73,7 @@ export const projectedFieldCeiling = (baseline: MunicipalityFederalBaseline): nu
|
|||
export const captureRate = (baseline: MunicipalityFederalBaseline): number | null => {
|
||||
const ceiling = fieldCeiling(baseline)
|
||||
if (ceiling <= 0) return null
|
||||
const votes2022 = baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0
|
||||
return votes2022 / ceiling
|
||||
return votesInYear(baseline, ELECTION_YEAR_2022) / ceiling
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,8 +86,7 @@ export const captureRate = (baseline: MunicipalityFederalBaseline): number | nul
|
|||
export const intraFieldShare = (baseline: MunicipalityFederalBaseline, year: number): number | null => {
|
||||
const campo = baseline.campoFederalVotesByYear[String(year)] ?? 0
|
||||
if (campo <= 0) return null
|
||||
const votes = baseline.votesByYear[String(year)] ?? 0
|
||||
return votes / campo
|
||||
return votesInYear(baseline, year) / campo
|
||||
}
|
||||
|
||||
export type RollOff = {
|
||||
|
|
@ -145,53 +148,103 @@ export const computeMunicipalityPotential = (slug: string): MunicipalityPotentia
|
|||
export const computeAllMunicipalityPotentials = (): MunicipalityPotential[] =>
|
||||
municipalityCatalog.map((entry) => computeMunicipalityPotential(entry.slug))
|
||||
|
||||
/** Fallback pessimistic haircut (as a share) when `campaignGoals.margin` is unset. */
|
||||
const DEFAULT_PESSIMISTIC_HAIRCUT = 0.1
|
||||
|
||||
/**
|
||||
* Decomposes the state goal proportionally to each municipality's projected
|
||||
* field ceiling — `suggestedGoal_i = stateGoal × projectedFieldCeiling_i / Σ
|
||||
* projectedFieldCeiling` — NOT a composite formula folding in capture rate or
|
||||
* intra-field share (those stay diagnostic columns; see the E8 plan's
|
||||
* "rabbit holes vigiados"). `margin` on the `campaignGoals` global is
|
||||
* informational for the table (a safety reading for the coordination), it
|
||||
* does not change the decomposed value.
|
||||
*
|
||||
* Falls back to an even split when every ceiling is zero/unknown (e.g. an
|
||||
* empty potentials list slice) rather than producing `NaN` suggested goals.
|
||||
* `campaignGoals.margin` is a percent (the global's field is "Margem (%)", so
|
||||
* `5` means 5%) with only `min: 0` enforced — clamped to [0, 1] as a share so
|
||||
* a mistyped 500 can't produce a negative pessimistic goal.
|
||||
*/
|
||||
export const decomposeStateGoal = (
|
||||
potentials: ReadonlyArray<Pick<MunicipalityPotential, 'slug' | 'projectedFieldCeiling'>>,
|
||||
goals: Pick<CampaignGoal, 'stateGoal'>,
|
||||
): Map<string, number> => {
|
||||
const stateGoal = goals.stateGoal
|
||||
const totalCeiling = potentials.reduce((sum, entry) => sum + entry.projectedFieldCeiling, 0)
|
||||
const suggestedGoalBySlug = new Map<string, number>()
|
||||
const pessimisticHaircut = (margin: number | null | undefined): number =>
|
||||
margin == null ? DEFAULT_PESSIMISTIC_HAIRCUT : Math.min(1, Math.max(0, margin / 100))
|
||||
|
||||
if (totalCeiling <= 0) {
|
||||
const even = potentials.length > 0 ? stateGoal / potentials.length : 0
|
||||
for (const entry of potentials) suggestedGoalBySlug.set(entry.slug, even)
|
||||
return suggestedGoalBySlug
|
||||
/** Own 2022 nominal votes — the anchor of every suggested goal (E9 revision). */
|
||||
export const ownVotes2022 = (baseline: MunicipalityFederalBaseline): number =>
|
||||
votesInYear(baseline, ELECTION_YEAR_2022)
|
||||
|
||||
/** Suggested goal for one município across the three scenarios. */
|
||||
export type SuggestedGoalByScenario = Record<VoteEstimateScenario, number>
|
||||
|
||||
/**
|
||||
* Suggested goal per município, per scenario, anchored on the candidate's OWN
|
||||
* 2022 vote in that município:
|
||||
*
|
||||
* - `pessimistic` = base × (1 − margin) — losing part of the base
|
||||
* - `central` = base — repeating 2022 where the network already produced
|
||||
* - `optimistic` = base × (stateGoal / Σ base) — growth that closes the seat
|
||||
* account, so Σ optimistic === `stateGoal` exactly
|
||||
*
|
||||
* The research report's other half — "fração do campo não capturado" — is
|
||||
* deliberately NOT folded in: the report conditions it on operational class
|
||||
* (defesa carries maintenance, ataque carries growth), which is E10's scope.
|
||||
* Until then the open field stays a diagnostic (`captureRate`,
|
||||
* `projectedFieldCeiling`). Why the E8 field-ceiling decomposition was
|
||||
* replaced: `docs/plans/conta-da-cadeira.md`.
|
||||
*
|
||||
* `margin` (the coordination's safety reading) is reused as the pessimistic
|
||||
* haircut rather than adding a fourth knob to the global.
|
||||
*
|
||||
* Guard: a `stateGoal` below Σ base would make the optimistic scenario land
|
||||
* BELOW central (an "optimistic" that loses votes), so the growth factor is
|
||||
* clamped at 1 and `belowBase` says so.
|
||||
*/
|
||||
export const deriveSuggestedGoalsByScenario = (
|
||||
slugs: ReadonlyArray<string>,
|
||||
goals: Pick<CampaignGoal, 'stateGoal' | 'margin'>,
|
||||
): {
|
||||
suggestedGoalBySlug: Map<string, SuggestedGoalByScenario>
|
||||
/** Σ of every `central` goal — the "repeat 2022" statewide total. */
|
||||
baseTotal: number
|
||||
/** `stateGoal / baseTotal`, clamped at 1 (see the guard above). */
|
||||
growthFactor: number
|
||||
/** True when `stateGoal` is below `baseTotal` and the growth factor was clamped. */
|
||||
belowBase: boolean
|
||||
} => {
|
||||
const baseBySlug = new Map<string, number>()
|
||||
let baseTotal = 0
|
||||
for (const slug of slugs) {
|
||||
const base = ownVotes2022(getMunicipalityFederalBaseline(slug))
|
||||
baseBySlug.set(slug, base)
|
||||
baseTotal += base
|
||||
}
|
||||
|
||||
for (const entry of potentials) {
|
||||
suggestedGoalBySlug.set(entry.slug, stateGoal * (entry.projectedFieldCeiling / totalCeiling))
|
||||
const rawGrowthFactor = baseTotal > 0 ? goals.stateGoal / baseTotal : 1
|
||||
const belowBase = baseTotal > 0 && rawGrowthFactor < 1
|
||||
const growthFactor = Math.max(1, rawGrowthFactor)
|
||||
const haircut = pessimisticHaircut(goals.margin)
|
||||
|
||||
const suggestedGoalBySlug = new Map<string, SuggestedGoalByScenario>()
|
||||
for (const [slug, base] of baseBySlug) {
|
||||
suggestedGoalBySlug.set(slug, {
|
||||
pessimistic: base * (1 - haircut),
|
||||
central: base,
|
||||
optimistic: base * growthFactor,
|
||||
})
|
||||
}
|
||||
return suggestedGoalBySlug
|
||||
|
||||
return { suggestedGoalBySlug, baseTotal, growthFactor, belowBase }
|
||||
}
|
||||
|
||||
export type TerritorySanityWarning = {
|
||||
region: BahiaIdentityTerritory
|
||||
suggestedGoalTotal: number
|
||||
projectedValidVotesTotal: number
|
||||
/** suggestedGoalTotal / projectedValidVotesTotal — > 1 means the decomposed goal exceeds the projected electorate. */
|
||||
/** suggestedGoalTotal / projectedValidVotesTotal — > 1 means the suggested goal exceeds the projected electorate. */
|
||||
ratio: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity check (never blocking, per the plan): flags identity territories
|
||||
* (TI) where the decomposed suggested goals, summed, exceed the projected
|
||||
* valid-vote electorate of that territory — a signal that the decomposition
|
||||
* needs a human look (state goal too high, or ceilings skewed), never a
|
||||
* thrown error. Callers surface `warnAboveRatio` as an `CampaignInfoHint`,
|
||||
* not a validation failure.
|
||||
* (TI) where the suggested goals, summed, exceed the projected valid-vote
|
||||
* electorate of that territory — a signal that the goals need a human look
|
||||
* (state goal too high), never a thrown error. Callers surface
|
||||
* `warnAboveRatio` as a `CampaignInfoHint`, not a validation failure.
|
||||
*
|
||||
* Since E9 anchored goals on the candidate's own vote, this is close to
|
||||
* inert (his base is a small share of any electorate); the plausibility
|
||||
* ceiling the research report actually asks for — Σ pledges above the local
|
||||
* FIELD ceiling — is a pledge signal, not a goal one, and belongs to E11.
|
||||
*/
|
||||
export const sanityCheckSuggestedGoalsByTerritory = (
|
||||
entries: ReadonlyArray<{
|
||||
|
|
@ -228,27 +281,25 @@ export const sanityCheckSuggestedGoalsByTerritory = (
|
|||
}
|
||||
|
||||
/**
|
||||
* Convenience end-to-end: computes every municipality's potential and
|
||||
* decomposes the state goal — the single entry point the dashboard/list/
|
||||
* detail loaders need (`municipalityGoalAccount.ts`), keyed by slug for O(1)
|
||||
* per-municipality lookup instead of a linear scan.
|
||||
* Every municipality's potential plus its suggested goal ladder — the single
|
||||
* entry point the dashboard/list/detail loaders need
|
||||
* (`municipalityGoalAccount.ts`), keyed by slug for O(1) lookup.
|
||||
*
|
||||
* Deliberately does NOT run `sanityCheckSuggestedGoalsByTerritory` here: no
|
||||
* caller surfaces `TerritorySanityWarning`s yet (deferred to E12's TI layer
|
||||
* — see the E8 plan), so computing them on every dashboard/list/detail
|
||||
* request would be pure wasted work. Callers that need the check once E12
|
||||
* lands can build the same `{ region, suggestedGoal, projectedValidVotes }`
|
||||
* entries from `potentialBySlug` + `municipalityCatalog` and call it
|
||||
* directly — it stays exported and unit-tested for that.
|
||||
* Deliberately skips `sanityCheckSuggestedGoalsByTerritory`: nothing surfaces
|
||||
* `TerritorySanityWarning`s yet (E12's TI layer), so running it on every
|
||||
* request would be wasted work.
|
||||
*/
|
||||
export const computeStatewideGoalDecomposition = (
|
||||
goals: Pick<CampaignGoal, 'stateGoal'>,
|
||||
export const computeStatewideSuggestedGoals = (
|
||||
goals: Pick<CampaignGoal, 'stateGoal' | 'margin'>,
|
||||
): {
|
||||
potentialBySlug: Map<string, MunicipalityPotential>
|
||||
suggestedGoalBySlug: Map<string, number>
|
||||
suggestedGoalBySlug: Map<string, SuggestedGoalByScenario>
|
||||
} => {
|
||||
const potentials = computeAllMunicipalityPotentials()
|
||||
const suggestedGoalBySlug = decomposeStateGoal(potentials, goals)
|
||||
const { suggestedGoalBySlug } = deriveSuggestedGoalsByScenario(
|
||||
potentials.map((potential) => potential.slug),
|
||||
goals,
|
||||
)
|
||||
const potentialBySlug = new Map(potentials.map((potential) => [potential.slug, potential]))
|
||||
|
||||
return { potentialBySlug, suggestedGoalBySlug }
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
strictDecimalInteger,
|
||||
type RawSearchParams as CampaignListRawSearchParams,
|
||||
} from '@/utilities/campaignListUrl'
|
||||
import { latestIsoTimestamp } from '@/utilities/campaignTime'
|
||||
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
|
||||
|
||||
export const municipalityPageSize = 25
|
||||
|
|
@ -49,11 +50,19 @@ export type MunicipalityListSortKey =
|
|||
| 'lastUpdateAt'
|
||||
| 'coverage'
|
||||
| 'votos'
|
||||
| 'deficit'
|
||||
| 'frescor'
|
||||
|
||||
export type MunicipalityListSortDirection = 'asc' | 'desc'
|
||||
|
||||
/** Mesa lens: list opens by 2022 vote concentration (desc). */
|
||||
export const DEFAULT_MUNICIPALITY_LIST_SORT_KEY: MunicipalityListSortKey = 'votos'
|
||||
/**
|
||||
* E9 allocation queue: the list opens on the decision it exists to serve —
|
||||
* where the goal is least covered by auditable commitments (biggest deficit
|
||||
* first), in the `central` scenario. Since E9 anchored the suggested goal on
|
||||
* the candidate's own 2022 vote, this ordering stays close to the previous
|
||||
* `votos` default instead of surfacing deserts with inflated goals.
|
||||
*/
|
||||
export const DEFAULT_MUNICIPALITY_LIST_SORT_KEY: MunicipalityListSortKey = 'deficit'
|
||||
export const DEFAULT_MUNICIPALITY_LIST_SORT_DIR: MunicipalityListSortDirection = 'desc'
|
||||
|
||||
export const municipalityListSortLabels: Record<MunicipalityListSortKey, string> = {
|
||||
|
|
@ -63,9 +72,12 @@ export const municipalityListSortLabels: Record<MunicipalityListSortKey, string>
|
|||
trend: 'Tendência',
|
||||
expectedVotes: 'Votos estimados',
|
||||
lastUpdateAt: 'Última atualização',
|
||||
coverage: 'Cobertura',
|
||||
/** Advisor coverage — "Cobertura" alone now reads as the goal one (`deficit`). */
|
||||
coverage: 'Assessoria',
|
||||
/** Short header — definition lives on hover (`formatMunicipalityConcentrationHint`). */
|
||||
votos: '2022',
|
||||
deficit: 'Cobertura da meta',
|
||||
frescor: 'Frescor do sinal',
|
||||
}
|
||||
|
||||
export type MunicipalityListState = {
|
||||
|
|
@ -99,16 +111,9 @@ export const municipalityListParamNames = [
|
|||
|
||||
const municipalityListParamNameSet = new Set<string>(municipalityListParamNames)
|
||||
|
||||
const municipalityListSortKeySet = new Set<MunicipalityListSortKey>([
|
||||
'name',
|
||||
'region',
|
||||
'kind',
|
||||
'trend',
|
||||
'expectedVotes',
|
||||
'lastUpdateAt',
|
||||
'coverage',
|
||||
'votos',
|
||||
])
|
||||
// Derived from the label record (total over the union), so a new sort key is
|
||||
// declared in the type and the labels only.
|
||||
const municipalityListSortKeySet = new Set<string>(Object.keys(municipalityListSortLabels))
|
||||
|
||||
const municipalityListSortDirSet = new Set<MunicipalityListSortDirection>(['asc', 'desc'])
|
||||
|
||||
|
|
@ -116,6 +121,9 @@ const sortKeysWithDescDefault: MunicipalityListSortKey[] = [
|
|||
'expectedVotes',
|
||||
'lastUpdateAt',
|
||||
'votos',
|
||||
// Both open on the worst case: biggest uncovered deficit, coldest signal.
|
||||
'deficit',
|
||||
'frescor',
|
||||
]
|
||||
|
||||
export const defaultMunicipalityListSortDir = (
|
||||
|
|
@ -135,6 +143,50 @@ export const formatMunicipalityConcentrationHint = (
|
|||
): string =>
|
||||
`Percentual da votação estadual do candidato neste município — não o % dos válidos locais. Colocação: posição no catálogo de ${formatElectionNumber(totalUnits)} unidades.`
|
||||
|
||||
/**
|
||||
* E9 frescor — the last time ANYBODY recorded something here: a staff update
|
||||
* or a leadership pledge declaration/estimate. One rule, shared by the server
|
||||
* ordering (`municipalityPageData.ts`) and the list cell, so "há N dias"
|
||||
* never disagrees with the position in the queue.
|
||||
*/
|
||||
export const resolveMunicipalityLastSignalAt = (
|
||||
lastUpdateAt: string | null,
|
||||
lastPledgeAt: string | null,
|
||||
): string | null => latestIsoTimestamp(lastUpdateAt, lastPledgeAt)
|
||||
|
||||
/**
|
||||
* Days since the last signal, floored. The research report only says a
|
||||
* commitment left untouched "for weeks" is worth less, so the threshold below
|
||||
* is a convention (3 weeks), not a measured decay curve.
|
||||
*/
|
||||
export const MUNICIPALITY_COLD_SIGNAL_DAYS = 21
|
||||
|
||||
export const municipalitySignalAgeInDays = (
|
||||
lastSignalAt: string | null,
|
||||
now: Date = new Date(),
|
||||
): number | null => {
|
||||
if (!lastSignalAt) return null
|
||||
const elapsed = now.getTime() - new Date(lastSignalAt).getTime()
|
||||
if (Number.isNaN(elapsed)) return null
|
||||
return Math.max(0, Math.floor(elapsed / 86_400_000))
|
||||
}
|
||||
|
||||
export const isMunicipalitySignalCold = (ageInDays: number | null): boolean =>
|
||||
ageInDays === null || ageInDays >= MUNICIPALITY_COLD_SIGNAL_DAYS
|
||||
|
||||
/**
|
||||
* "há 3 dias" / "hoje" / "Sem sinal" — dense cell copy for the queue.
|
||||
* Deliberately not `formatRelativeAge`: its `numeric: 'auto'` yields "ontem"/
|
||||
* "anteontem" and minute/hour granularity, which breaks both the day-based
|
||||
* cold threshold and the tabular-nums scan down the column.
|
||||
*/
|
||||
export const formatMunicipalitySignalAgeLabel = (ageInDays: number | null): string => {
|
||||
if (ageInDays === null) return 'Sem sinal'
|
||||
if (ageInDays === 0) return 'hoje'
|
||||
if (ageInDays === 1) return 'há 1 dia'
|
||||
return `há ${ageInDays} dias`
|
||||
}
|
||||
|
||||
export const formatMunicipalityListSortSummary = (
|
||||
sort: MunicipalityListSortKey,
|
||||
dir: MunicipalityListSortDirection,
|
||||
|
|
@ -145,6 +197,16 @@ export const formatMunicipalityListSortSummary = (
|
|||
if (sort === 'name') {
|
||||
return dir === 'asc' ? 'Ordenado por nome (A–Z)' : 'Ordenado por nome (Z–A)'
|
||||
}
|
||||
if (sort === 'deficit') {
|
||||
return dir === 'desc'
|
||||
? 'Ordenado por déficit da meta (maior primeiro)'
|
||||
: 'Ordenado por déficit da meta (menor primeiro)'
|
||||
}
|
||||
if (sort === 'frescor') {
|
||||
return dir === 'desc'
|
||||
? 'Ordenado por frescor (sinal mais frio primeiro)'
|
||||
: 'Ordenado por frescor (sinal mais recente primeiro)'
|
||||
}
|
||||
const label = municipalityListSortLabels[sort]
|
||||
return dir === 'desc' ? `Ordenado por ${label} ↓` : `Ordenado por ${label} ↑`
|
||||
}
|
||||
|
|
@ -298,6 +360,12 @@ export const formatMunicipalitySortOptionLabel = (
|
|||
if (key === 'lastUpdateAt') {
|
||||
return dir === 'asc' ? `${base} (mais antiga)` : `${base} (mais recente)`
|
||||
}
|
||||
if (key === 'deficit') {
|
||||
return dir === 'asc' ? `${base} (mais coberta)` : `${base} (mais descoberta)`
|
||||
}
|
||||
if (key === 'frescor') {
|
||||
return dir === 'asc' ? `${base} (mais recente)` : `${base} (mais frio)`
|
||||
}
|
||||
return dir === 'asc' ? `${base} (A–Z)` : `${base} (Z–A)`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
|
|||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
|
||||
import { createEmptyGoalCoverageByScenario, type MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
|
||||
import type { PoliticalTrendStatus } from '@/utilities/municipalityUi'
|
||||
import {
|
||||
resolveMunicipalityLastSignalAt,
|
||||
type PoliticalTrendStatus,
|
||||
} from '@/utilities/municipalityUi'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
|
||||
import {
|
||||
|
|
@ -52,6 +55,12 @@ export type MunicipalityListViewModel = {
|
|||
advisorIDs: number[]
|
||||
priority: 'alta' | 'normal'
|
||||
lastUpdateAt: string | null
|
||||
/**
|
||||
* E9 frescor — `max(lastUpdateAt, pledges.lastPledgeAt)`, resolved once on
|
||||
* the server so the "há N dias" in the cell always matches the `frescor`
|
||||
* ordering.
|
||||
*/
|
||||
lastSignalAt: string | null
|
||||
expectedVotes: VoteEstimateScenarioViewModel
|
||||
politicalTrendStatus: PoliticalTrendStatus | null
|
||||
politicalTrendNote: string | null
|
||||
|
|
@ -78,6 +87,10 @@ export const toMunicipalityListViewModel = (
|
|||
advisorIDs: (municipality.advisors ?? []).map(relationshipId).filter((id): id is number => id !== null),
|
||||
priority: municipality.priority === 'alta' ? 'alta' : 'normal',
|
||||
lastUpdateAt: municipality.lastUpdateAt ?? null,
|
||||
lastSignalAt: resolveMunicipalityLastSignalAt(
|
||||
municipality.lastUpdateAt ?? null,
|
||||
pledges?.lastPledgeAt ?? null,
|
||||
),
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes),
|
||||
politicalTrendStatus: municipality.politicalTrend?.status ?? null,
|
||||
politicalTrendNote: municipality.politicalTrend?.note ?? null,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ export type VoteEstimateScenarioViewModel = {
|
|||
optimistic: number | null
|
||||
}
|
||||
|
||||
/** Fresh zeroed record per scenario — a factory because callers accumulate into it. */
|
||||
export const zeroByVoteEstimateScenario = (): Record<VoteEstimateScenario, number> => ({
|
||||
pessimistic: 0,
|
||||
central: 0,
|
||||
optimistic: 0,
|
||||
})
|
||||
|
||||
/** True when at least one scenario has a recorded estimate. */
|
||||
export const hasAnyEstimate = (estimates: VoteEstimateScenarioViewModel): boolean =>
|
||||
estimates.pessimistic != null || estimates.central != null || estimates.optimistic != null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Payload, PayloadRequest } from 'payload'
|
||||
|
||||
import type { CampaignUser, VotePledge } from '@/payload-types'
|
||||
import { latestIsoTimestamp } from '@/utilities/campaignTime'
|
||||
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
|
||||
import {
|
||||
DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
|
|
@ -8,6 +9,7 @@ import {
|
|||
resolveMunicipalityStaffVoteTotalForScenario,
|
||||
toVoteEstimateScenarioViewModel,
|
||||
VOTE_ESTIMATE_SCENARIOS,
|
||||
zeroByVoteEstimateScenario,
|
||||
type VoteEstimateScenario,
|
||||
type VoteEstimateScenarioFields,
|
||||
type VoteEstimateScenarioViewModel,
|
||||
|
|
@ -21,24 +23,26 @@ import {
|
|||
|
||||
export type { VoteEstimateScenario, VoteEstimateScenarioViewModel }
|
||||
|
||||
const emptyEffectiveByScenario = (): Record<VoteEstimateScenario, number> => ({
|
||||
pessimistic: 0,
|
||||
central: 0,
|
||||
optimistic: 0,
|
||||
})
|
||||
|
||||
export type MunicipalityPledgeAggregate = {
|
||||
declaredTotal: number
|
||||
effectiveByScenario: Record<VoteEstimateScenario, number>
|
||||
pledgeCount: number
|
||||
missingEstimateCount: number
|
||||
/**
|
||||
* Most recent `declaredAt`/`estimatedAt` across the município's pledges —
|
||||
* E9 freshness: a commitment nobody has touched in weeks is worth less
|
||||
* (`docs/research`, l. 339), so the allocation queue can order by how cold
|
||||
* the signal is. `null` when no pledge carries a date.
|
||||
*/
|
||||
lastPledgeAt: string | null
|
||||
}
|
||||
|
||||
export const createEmptyMunicipalityPledgeAggregate = (): MunicipalityPledgeAggregate => ({
|
||||
declaredTotal: 0,
|
||||
effectiveByScenario: emptyEffectiveByScenario(),
|
||||
effectiveByScenario: zeroByVoteEstimateScenario(),
|
||||
pledgeCount: 0,
|
||||
missingEstimateCount: 0,
|
||||
lastPledgeAt: null,
|
||||
})
|
||||
|
||||
/** Read-only zero aggregate. Never mutate — use createEmptyMunicipalityPledgeAggregate() when writing. */
|
||||
|
|
@ -77,6 +81,8 @@ export const aggregateMunicipalityPledgesFromRows = (
|
|||
rows: ReadonlyArray<{
|
||||
declaredVotes: number
|
||||
estimatedVotes?: VoteEstimateScenarioFields | null
|
||||
declaredAt?: string | null
|
||||
estimatedAt?: string | null
|
||||
}>,
|
||||
): MunicipalityPledgeAggregate => {
|
||||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
|
|
@ -93,6 +99,10 @@ export const aggregateMunicipalityPledgesFromRows = (
|
|||
}
|
||||
aggregate.pledgeCount += 1
|
||||
if (!pledgeHasAnyEstimate(row.estimatedVotes)) aggregate.missingEstimateCount += 1
|
||||
aggregate.lastPledgeAt = latestIsoTimestamp(
|
||||
aggregate.lastPledgeAt,
|
||||
latestIsoTimestamp(row.declaredAt, row.estimatedAt),
|
||||
)
|
||||
}
|
||||
|
||||
return aggregate
|
||||
|
|
@ -112,7 +122,7 @@ export const rollupMunicipalityStaffVotes = (
|
|||
scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO,
|
||||
): MunicipalityStaffVoteRollup => {
|
||||
let staffVoteTotal = 0
|
||||
const staffVoteTotalByScenario = emptyEffectiveByScenario()
|
||||
const staffVoteTotalByScenario = zeroByVoteEstimateScenario()
|
||||
let declaredVotesTotal = 0
|
||||
let pledgeCount = 0
|
||||
let missingEstimateCount = 0
|
||||
|
|
@ -172,16 +182,21 @@ export const aggregatePledgesByMunicipality = async (
|
|||
central: true,
|
||||
optimistic: true,
|
||||
},
|
||||
// E9 freshness (`lastPledgeAt`) — the queue orders by how cold the signal is.
|
||||
declaredAt: true,
|
||||
estimatedAt: true,
|
||||
},
|
||||
overrideAccess: true,
|
||||
...(req ? { req } : {}),
|
||||
})
|
||||
|
||||
for (const doc of result.docs) {
|
||||
const municipalityID = relationshipId((doc as VotePledge).municipality)
|
||||
// `select` narrows the runtime shape, not the static type Payload returns.
|
||||
const pledge = doc as VotePledge
|
||||
const municipalityID = relationshipId(pledge.municipality)
|
||||
if (municipalityID === null) continue
|
||||
const declared = (doc as VotePledge).declaredVotes ?? 0
|
||||
const estimated = (doc as VotePledge).estimatedVotes
|
||||
const declared = pledge.declaredVotes ?? 0
|
||||
const estimated = pledge.estimatedVotes
|
||||
const current = aggregates.get(municipalityID) ?? createEmptyMunicipalityPledgeAggregate()
|
||||
current.declaredTotal += declared
|
||||
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
|
||||
|
|
@ -193,6 +208,10 @@ export const aggregatePledgesByMunicipality = async (
|
|||
}
|
||||
current.pledgeCount += 1
|
||||
if (!pledgeHasAnyEstimate(estimated)) current.missingEstimateCount += 1
|
||||
current.lastPledgeAt = latestIsoTimestamp(
|
||||
current.lastPledgeAt,
|
||||
latestIsoTimestamp(pledge.declaredAt, pledge.estimatedAt),
|
||||
)
|
||||
aggregates.set(municipalityID, current)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,16 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||||
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
|
||||
|
||||
// E9 allocation queue: the list opens on the uncovered deficit and exposes
|
||||
// the freshness column the ordering is paired with. `exact` keeps this off
|
||||
// the table caption, which embeds the same summary plus column glossary.
|
||||
await expect(
|
||||
page.getByText('Ordenado por déficit da meta (maior primeiro)', { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: /Ordenar por Frescor do sinal/ }),
|
||||
).toBeVisible()
|
||||
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
|
||||
await expect(page.getByRole('heading', { name: municipality.name })).toBeVisible()
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,154 @@ describe('loadMunicipalityListPageBundle', () => {
|
|||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* E9 allocation queue: the default ordering. Uses `expectedVotes` (mesa
|
||||
* goals) so the deficits are pinned by the fixture instead of by whatever
|
||||
* the 2022 artifact says for the allocated slugs.
|
||||
*/
|
||||
it('orders by uncovered deficit (default sort), biggest gap first', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const wideGap = await fixtures.getMunicipality()
|
||||
const nearlyCovered = await fixtures.getMunicipality()
|
||||
|
||||
const marker = `e9-deficit-${Date.now()}`
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: wideGap.id,
|
||||
data: {
|
||||
name: `${marker}-wide`,
|
||||
expectedVotes: { pessimistic: null, central: 5_000, optimistic: null },
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
fixtures.touchMunicipality(wideGap.id)
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: nearlyCovered.id,
|
||||
data: {
|
||||
name: `${marker}-near`,
|
||||
expectedVotes: { pessimistic: null, central: 1_000, optimistic: null },
|
||||
},
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
fixtures.touchMunicipality(nearlyCovered.id)
|
||||
|
||||
const contact = await fixtures.createContact()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
municipalities: [nearlyCovered.id],
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
await fixtures.createVotePledge({
|
||||
leadership: leadership.id,
|
||||
municipality: nearlyCovered.id,
|
||||
declaredVotes: 900,
|
||||
estimatedVotes: { pessimistic: null, central: 900, optimistic: null },
|
||||
})
|
||||
|
||||
const descending = await loadMunicipalityListPageBundle(payload, coordinator, { q: marker })
|
||||
expect(descending.municipalities.map((row) => row.slug)).toEqual([
|
||||
wideGap.slug,
|
||||
nearlyCovered.slug,
|
||||
])
|
||||
expect(descending.municipalities[0]!.goalCoverageByScenario.central.deficit).toBe(5_000)
|
||||
expect(descending.municipalities[1]!.goalCoverageByScenario.central.deficit).toBe(100)
|
||||
|
||||
const ascending = await loadMunicipalityListPageBundle(payload, coordinator, {
|
||||
q: marker,
|
||||
sort: 'deficit',
|
||||
dir: 'asc',
|
||||
})
|
||||
expect(ascending.municipalities.map((row) => row.slug)).toEqual([
|
||||
nearlyCovered.slug,
|
||||
wideGap.slug,
|
||||
])
|
||||
})
|
||||
|
||||
it('orders by frescor with "never had a signal" ahead of the oldest signal', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const silent = await fixtures.getMunicipality()
|
||||
const touched = await fixtures.getMunicipality()
|
||||
|
||||
const marker = `e9-frescor-${Date.now()}`
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: silent.id,
|
||||
data: { name: `${marker}-silent`, lastUpdateAt: null },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
fixtures.touchMunicipality(silent.id)
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: touched.id,
|
||||
data: { name: `${marker}-touched` },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
// The collection hook recomputes `lastUpdateAt` from this update.
|
||||
await fixtures.createMunicipalityUpdate({
|
||||
municipality: touched.id,
|
||||
author: coordinator.id,
|
||||
})
|
||||
|
||||
const coldestFirst = await loadMunicipalityListPageBundle(payload, coordinator, {
|
||||
q: marker,
|
||||
sort: 'frescor',
|
||||
})
|
||||
expect(coldestFirst.municipalities.map((row) => row.slug)).toEqual([silent.slug, touched.slug])
|
||||
expect(coldestFirst.municipalities[0]!.lastSignalAt).toBeNull()
|
||||
expect(coldestFirst.municipalities[1]!.lastSignalAt).not.toBeNull()
|
||||
|
||||
const freshestFirst = await loadMunicipalityListPageBundle(payload, coordinator, {
|
||||
q: marker,
|
||||
sort: 'frescor',
|
||||
dir: 'asc',
|
||||
})
|
||||
expect(freshestFirst.municipalities.map((row) => row.slug)).toEqual([touched.slug, silent.slug])
|
||||
})
|
||||
|
||||
it('counts a pledge date as a signal even when no staff update exists', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||||
const municipality = await fixtures.getMunicipality()
|
||||
fixtures.touchMunicipality(municipality.id)
|
||||
|
||||
const marker = `e9-pledge-signal-${Date.now()}`
|
||||
await payload.update({
|
||||
collection: 'municipality',
|
||||
id: municipality.id,
|
||||
data: { name: marker, lastUpdateAt: null },
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const contact = await fixtures.createContact()
|
||||
const leadership = await fixtures.createLeadership({
|
||||
contact: contact.id,
|
||||
municipalities: [municipality.id],
|
||||
supportStatus: 'engajado',
|
||||
})
|
||||
// `declaredAt` is stamped by the collection's own beforeChange hook, so the
|
||||
// fixture cannot pin it — read it back and assert the signal matches.
|
||||
const pledge = await fixtures.createVotePledge({
|
||||
leadership: leadership.id,
|
||||
municipality: municipality.id,
|
||||
declaredVotes: 50,
|
||||
})
|
||||
expect(pledge.declaredAt).toBeTruthy()
|
||||
|
||||
const bundle = await loadMunicipalityListPageBundle(payload, coordinator, { q: marker })
|
||||
const row = bundle.municipalities.find((item) => item.slug === municipality.slug)
|
||||
expect(row?.lastUpdateAt).toBeNull()
|
||||
expect(row?.lastSignalAt).toBe(pledge.declaredAt)
|
||||
expect(row?.pledges.lastPledgeAt).toBe(pledge.declaredAt)
|
||||
})
|
||||
|
||||
it('keeps advisor access and applies URL filters on top', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ describe('campaign visual foundation', () => {
|
|||
advisorIDs: [advisor.id],
|
||||
priority: 'alta',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
expectedVotes: { pessimistic: null, central: 1500, optimistic: null },
|
||||
politicalTrendStatus: 'favoravel',
|
||||
politicalTrendNote: null,
|
||||
|
|
@ -168,6 +169,7 @@ describe('campaign visual foundation', () => {
|
|||
effectiveByScenario: { pessimistic: 1200, central: 1200, optimistic: 1200 },
|
||||
pledgeCount: 2,
|
||||
missingEstimateCount: 1,
|
||||
lastPledgeAt: null,
|
||||
},
|
||||
votePosition2022: {
|
||||
votes: 4200,
|
||||
|
|
@ -193,6 +195,7 @@ describe('campaign visual foundation', () => {
|
|||
advisorIDs: [],
|
||||
priority: 'normal',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
|
|
@ -225,6 +228,88 @@ describe('campaign visual foundation', () => {
|
|||
expect(html).toContain('Faltam 300 votos para a meta')
|
||||
})
|
||||
|
||||
/**
|
||||
* E9 allocation queue: the two row-level signals the queue is scanned for —
|
||||
* a priority município nobody answers for, and a signal that went cold.
|
||||
*/
|
||||
it('names a priority município with no advisor and ages its last signal', () => {
|
||||
const staleDays = 40
|
||||
const staleSignal = new Date(Date.now() - staleDays * 86_400_000).toISOString()
|
||||
|
||||
const html = renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities: [
|
||||
{
|
||||
id: 9,
|
||||
name: 'Barreiras',
|
||||
slug: 'barreiras',
|
||||
kind: 'municipio',
|
||||
city: 'Barreiras',
|
||||
region: 'Bacia do Rio Grande',
|
||||
ibgeCode: '2903201',
|
||||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
priority: 'alta',
|
||||
lastUpdateAt: staleSignal,
|
||||
lastSignalAt: staleSignal,
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022: null,
|
||||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||||
},
|
||||
],
|
||||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||||
isStaffView: true,
|
||||
...municipalityListDefaultProps,
|
||||
}),
|
||||
)
|
||||
|
||||
// "Sem assessor" still appears in the Assessores column (the avatar stack's
|
||||
// empty state); the status badge is what escalates to "Sem responsável".
|
||||
expect(html).toContain('Sem responsável')
|
||||
expect(html).toContain(`há ${staleDays} dias`)
|
||||
expect(html).toContain('data-signal="cold"')
|
||||
expect(html).toContain('Último sinal')
|
||||
})
|
||||
|
||||
it('reads "Sem sinal" when nothing was ever recorded for the município', () => {
|
||||
const html = renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
municipalities: [
|
||||
{
|
||||
id: 10,
|
||||
name: 'Uauá',
|
||||
slug: 'uaua',
|
||||
kind: 'municipio',
|
||||
city: 'Uauá',
|
||||
region: 'Semiárido Nordeste II',
|
||||
ibgeCode: '2932002',
|
||||
zoneNumber: null,
|
||||
advisorIDs: [],
|
||||
priority: 'normal',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
pledges: createEmptyMunicipalityPledgeAggregate(),
|
||||
votePosition2022: null,
|
||||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||||
},
|
||||
],
|
||||
advisorNamesById: new Map<number, MunicipalityAdvisorSummary>(),
|
||||
isStaffView: true,
|
||||
...municipalityListDefaultProps,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('Sem sinal')
|
||||
expect(html).toContain('Sem assessor')
|
||||
expect(html).not.toContain('Sem responsável')
|
||||
})
|
||||
|
||||
it('hides leadership coverage subline when pledges only have declared votes', () => {
|
||||
const html = renderWithAppRouter(
|
||||
createElement(MunicipalityList, {
|
||||
|
|
@ -241,6 +326,7 @@ describe('campaign visual foundation', () => {
|
|||
advisorIDs: [],
|
||||
priority: 'normal',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
|
|
@ -249,6 +335,7 @@ describe('campaign visual foundation', () => {
|
|||
effectiveByScenario: { pessimistic: 800, central: 800, optimistic: 800 },
|
||||
pledgeCount: 1,
|
||||
missingEstimateCount: 1,
|
||||
lastPledgeAt: null,
|
||||
},
|
||||
votePosition2022: null,
|
||||
goalCoverageByScenario: createEmptyGoalCoverageByScenario(),
|
||||
|
|
@ -281,6 +368,7 @@ describe('campaign visual foundation', () => {
|
|||
advisorIDs: [],
|
||||
priority: 'alta',
|
||||
lastUpdateAt: null,
|
||||
lastSignalAt: null,
|
||||
expectedVotes: toVoteEstimateScenarioViewModel(null),
|
||||
politicalTrendStatus: null,
|
||||
politicalTrendNote: null,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,15 @@ import {
|
|||
computeGoalCoverageByScenario,
|
||||
formatGoalCoverageDeficitLabel,
|
||||
} from '@/utilities/goalCoverage'
|
||||
import type { SuggestedGoalByScenario } from '@/utilities/municipalityPotential'
|
||||
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeData'
|
||||
|
||||
/** Suggested goal ladder shaped like `deriveSuggestedGoalsByScenario` output. */
|
||||
const suggested = (
|
||||
central: number,
|
||||
{ pessimistic = central * 0.9, optimistic = central * 1.16 } = {},
|
||||
): SuggestedGoalByScenario => ({ pessimistic, central, optimistic })
|
||||
|
||||
/**
|
||||
* Fixes the meta×comprometido semantics locked in the E8 plan audit: a
|
||||
* municipality can have `expectedVotes` filled by the E4R projection-sheet
|
||||
|
|
@ -22,7 +29,7 @@ describe('computeGoalCoverage', () => {
|
|||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
aggregate.effectiveByScenario.central = 1200
|
||||
|
||||
const coverage = computeGoalCoverage({ central: 3000 }, 5000, aggregate, 'central')
|
||||
const coverage = computeGoalCoverage({ central: 3000 }, suggested(5000), aggregate, 'central')
|
||||
|
||||
expect(coverage.goal).toBe(3000) // NOT 100% — expectedVotes wins over suggestedGoal, not over pledges
|
||||
expect(coverage.committed).toBe(1200)
|
||||
|
|
@ -34,7 +41,7 @@ describe('computeGoalCoverage', () => {
|
|||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
aggregate.effectiveByScenario.central = 400
|
||||
|
||||
const coverage = computeGoalCoverage(null, 1000, aggregate, 'central')
|
||||
const coverage = computeGoalCoverage(null, suggested(1000), aggregate, 'central')
|
||||
expect(coverage.goal).toBe(1000)
|
||||
expect(coverage.committed).toBe(400)
|
||||
expect(coverage.coverageRatio).toBeCloseTo(0.4, 6)
|
||||
|
|
@ -42,7 +49,7 @@ describe('computeGoalCoverage', () => {
|
|||
|
||||
it('coverageRatio is null (not Infinity/NaN) when goal is zero', () => {
|
||||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
const coverage = computeGoalCoverage(null, 0, aggregate, 'central')
|
||||
const coverage = computeGoalCoverage(null, suggested(0), aggregate, 'central')
|
||||
expect(coverage.coverageRatio).toBeNull()
|
||||
expect(coverage.deficit).toBe(0)
|
||||
})
|
||||
|
|
@ -50,7 +57,7 @@ describe('computeGoalCoverage', () => {
|
|||
it('deficit is negative when the municipality is over-committed relative to its goal', () => {
|
||||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
aggregate.effectiveByScenario.central = 1500
|
||||
const coverage = computeGoalCoverage(null, 1000, aggregate, 'central')
|
||||
const coverage = computeGoalCoverage(null, suggested(1000), aggregate, 'central')
|
||||
expect(coverage.deficit).toBe(-500)
|
||||
expect(coverage.coverageRatio).toBeCloseTo(1.5, 6)
|
||||
})
|
||||
|
|
@ -63,7 +70,8 @@ describe('computeGoalCoverage', () => {
|
|||
|
||||
const byScenario = computeGoalCoverageByScenario(
|
||||
{ pessimistic: 500, optimistic: 900 },
|
||||
600, // suggestedGoal fallback used only for 'central' (unset in expectedVotes)
|
||||
// Suggested fallback used only for 'central' here (unset in expectedVotes).
|
||||
suggested(600, { pessimistic: 540, optimistic: 700 }),
|
||||
aggregate,
|
||||
)
|
||||
|
||||
|
|
@ -74,13 +82,26 @@ describe('computeGoalCoverage', () => {
|
|||
expect(byScenario.optimistic.goal).toBe(900)
|
||||
expect(byScenario.optimistic.committed).toBe(300)
|
||||
})
|
||||
|
||||
it('the suggested fallback moves with the scenario (E9: both sides are per-scenario)', () => {
|
||||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
const byScenario = computeGoalCoverageByScenario(
|
||||
null,
|
||||
suggested(1000, { pessimistic: 900, optimistic: 1163 }),
|
||||
aggregate,
|
||||
)
|
||||
|
||||
expect(byScenario.pessimistic.goal).toBe(900)
|
||||
expect(byScenario.central.goal).toBe(1000)
|
||||
expect(byScenario.optimistic.goal).toBe(1163)
|
||||
})
|
||||
})
|
||||
|
||||
describe('goal coverage labels', () => {
|
||||
it('renders a fractional decomposed goal as whole votes', () => {
|
||||
// A suggested goal of 100.968 votes must not read as "100,968" in pt-BR.
|
||||
const aggregate = createEmptyMunicipalityPledgeAggregate()
|
||||
const coverage = computeGoalCoverage(null, 100.968, aggregate, 'central')
|
||||
const coverage = computeGoalCoverage(null, suggested(100.968), aggregate, 'central')
|
||||
expect(formatGoalCoverageDeficitLabel(coverage)).toBe('Faltam 101 votos para a meta')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { MunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
|
||||
import {
|
||||
getMunicipalityFederalBaseline,
|
||||
type MunicipalityFederalBaseline,
|
||||
} from '@/lib/bahiaElectionAggregates'
|
||||
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
|
||||
import { municipalityCatalog } from '@/lib/municipalityCatalog'
|
||||
import {
|
||||
captureRate,
|
||||
decomposeStateGoal,
|
||||
deriveSuggestedGoalsByScenario,
|
||||
fieldCeiling,
|
||||
intraFieldShare,
|
||||
ownVotes2022,
|
||||
projectedFieldCeiling,
|
||||
projectedValidVotes,
|
||||
rollOff,
|
||||
|
|
@ -136,35 +141,109 @@ describe('rollOff', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('decomposeStateGoal', () => {
|
||||
it('splits the state goal proportionally to projectedFieldCeiling, summing back to it', () => {
|
||||
const potentials = [
|
||||
{ slug: 'a', projectedFieldCeiling: 1000 },
|
||||
{ slug: 'b', projectedFieldCeiling: 3000 },
|
||||
{ slug: 'c', projectedFieldCeiling: 6000 },
|
||||
]
|
||||
const suggested = decomposeStateGoal(potentials, { stateGoal: 150_000 })
|
||||
/**
|
||||
* E9 revision of the E8 formula: the suggested goal is anchored on the
|
||||
* candidate's OWN 2022 vote per município, not on the field's majoritarian
|
||||
* ceiling. The three scenarios are the same anchor at three ambitions, and
|
||||
* `stateGoal` survives as the optimistic growth factor so the seat account
|
||||
* still closes exactly.
|
||||
*/
|
||||
describe('deriveSuggestedGoalsByScenario', () => {
|
||||
const catalogSlugs = () => municipalityCatalog.map((entry) => entry.slug)
|
||||
|
||||
expect(suggested.get('a')).toBeCloseTo(15_000, 6)
|
||||
expect(suggested.get('b')).toBeCloseTo(45_000, 6)
|
||||
expect(suggested.get('c')).toBeCloseTo(90_000, 6)
|
||||
it('central repeats the own 2022 vote, and Σ optimistic equals the state goal exactly', () => {
|
||||
const slugs = catalogSlugs()
|
||||
const { suggestedGoalBySlug, baseTotal, growthFactor, belowBase } =
|
||||
deriveSuggestedGoalsByScenario(slugs, { stateGoal: 150_000, margin: 10 })
|
||||
|
||||
const total = [...suggested.values()].reduce((sum, value) => sum + value, 0)
|
||||
expect(total).toBeCloseTo(150_000, 6)
|
||||
expect(suggestedGoalBySlug.size).toBe(slugs.length)
|
||||
expect(belowBase).toBe(false)
|
||||
expect(growthFactor).toBeGreaterThan(1)
|
||||
|
||||
const goals = [...suggestedGoalBySlug.values()]
|
||||
const sum = (scenario: 'pessimistic' | 'central' | 'optimistic') =>
|
||||
goals.reduce((total, goal) => total + goal[scenario], 0)
|
||||
|
||||
expect(sum('central')).toBeCloseTo(baseTotal, 6)
|
||||
expect(sum('optimistic')).toBeCloseTo(150_000, 4)
|
||||
expect(sum('pessimistic')).toBeCloseTo(baseTotal * 0.9, 4)
|
||||
})
|
||||
|
||||
it('falls back to an even split when every ceiling is zero (never NaN)', () => {
|
||||
const potentials = [
|
||||
{ slug: 'a', projectedFieldCeiling: 0 },
|
||||
{ slug: 'b', projectedFieldCeiling: 0 },
|
||||
]
|
||||
const suggested = decomposeStateGoal(potentials, { stateGoal: 100_000 })
|
||||
expect(suggested.get('a')).toBe(50_000)
|
||||
expect(suggested.get('b')).toBe(50_000)
|
||||
it('keeps a stronghold at or above the votes it already produced (the E8 formula did not)', () => {
|
||||
const slug = 'vitoria-da-conquista'
|
||||
const own2022 = ownVotes2022(getMunicipalityFederalBaseline(slug))
|
||||
expect(own2022).toBeGreaterThan(1000) // guards the fixture, not the formula
|
||||
|
||||
const { suggestedGoalBySlug } = deriveSuggestedGoalsByScenario(catalogSlugs(), {
|
||||
stateGoal: 150_000,
|
||||
margin: 10,
|
||||
})
|
||||
const goal = suggestedGoalBySlug.get(slug)
|
||||
|
||||
expect(goal?.central).toBe(own2022)
|
||||
expect(goal?.optimistic).toBeGreaterThan(own2022)
|
||||
})
|
||||
|
||||
it('returns an empty map for an empty potentials list', () => {
|
||||
expect(decomposeStateGoal([], { stateGoal: 150_000 }).size).toBe(0)
|
||||
it('leaves a desert near its own vote instead of inflating it (E8 asked 813 for 47 votes)', () => {
|
||||
const slug = 'campo-formoso'
|
||||
const own2022 = ownVotes2022(getMunicipalityFederalBaseline(slug))
|
||||
expect(own2022).toBeLessThan(100) // guards the fixture, not the formula
|
||||
|
||||
const { suggestedGoalBySlug, growthFactor } = deriveSuggestedGoalsByScenario(catalogSlugs(), {
|
||||
stateGoal: 150_000,
|
||||
margin: 10,
|
||||
})
|
||||
const goal = suggestedGoalBySlug.get(slug)
|
||||
|
||||
// Growth is the same statewide factor everywhere — a desert cannot be
|
||||
// handed a goal an order of magnitude above the votes it produced.
|
||||
expect(goal?.optimistic).toBeCloseTo(own2022 * growthFactor, 6)
|
||||
expect(goal?.optimistic).toBeLessThan(own2022 * 2)
|
||||
})
|
||||
|
||||
it('clamps the growth factor at 1 when the state goal sits below the projected base', () => {
|
||||
const { suggestedGoalBySlug, baseTotal, growthFactor, belowBase } =
|
||||
deriveSuggestedGoalsByScenario(catalogSlugs(), { stateGoal: 1000, margin: 10 })
|
||||
|
||||
expect(belowBase).toBe(true)
|
||||
expect(growthFactor).toBe(1)
|
||||
|
||||
const goals = [...suggestedGoalBySlug.values()]
|
||||
// An "optimistic" scenario must never land below central.
|
||||
for (const goal of goals) expect(goal.optimistic).toBeGreaterThanOrEqual(goal.central)
|
||||
expect(goals.reduce((total, goal) => total + goal.optimistic, 0)).toBeCloseTo(baseTotal, 6)
|
||||
})
|
||||
|
||||
it('defaults the pessimistic haircut to 10% when margin is unset, and clamps a mistyped one', () => {
|
||||
const slugs = catalogSlugs()
|
||||
const unset = deriveSuggestedGoalsByScenario(slugs, { stateGoal: 150_000, margin: null })
|
||||
const mistyped = deriveSuggestedGoalsByScenario(slugs, { stateGoal: 150_000, margin: 500 })
|
||||
|
||||
const slug = 'vitoria-da-conquista'
|
||||
const own2022 = ownVotes2022(getMunicipalityFederalBaseline(slug))
|
||||
|
||||
expect(unset.suggestedGoalBySlug.get(slug)?.pessimistic).toBeCloseTo(own2022 * 0.9, 6)
|
||||
expect(mistyped.suggestedGoalBySlug.get(slug)?.pessimistic).toBe(0)
|
||||
})
|
||||
|
||||
it('returns an empty map for an empty slug list (never NaN)', () => {
|
||||
const result = deriveSuggestedGoalsByScenario([], { stateGoal: 150_000, margin: 10 })
|
||||
expect(result.suggestedGoalBySlug.size).toBe(0)
|
||||
expect(result.baseTotal).toBe(0)
|
||||
expect(result.growthFactor).toBe(1)
|
||||
expect(result.belowBase).toBe(false)
|
||||
})
|
||||
|
||||
it('gives an unknown slug a zero goal in every scenario', () => {
|
||||
const { suggestedGoalBySlug } = deriveSuggestedGoalsByScenario(['nao-existe'], {
|
||||
stateGoal: 150_000,
|
||||
margin: 10,
|
||||
})
|
||||
expect(suggestedGoalBySlug.get('nao-existe')).toEqual({
|
||||
pessimistic: 0,
|
||||
central: 0,
|
||||
optimistic: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -200,7 +279,6 @@ describe('sanityCheckSuggestedGoalsByTerritory', () => {
|
|||
describe('computeMunicipalityPotential / computeAllMunicipalityPotentials (real artifact)', () => {
|
||||
it('returns a well-shaped potential for a real catalog slug and a zeroed one for an unknown slug', async () => {
|
||||
const { computeMunicipalityPotential } = await import('@/utilities/municipalityPotential')
|
||||
const { municipalityCatalog } = await import('@/lib/municipalityCatalog')
|
||||
|
||||
const realSlug = municipalityCatalog[0]!.slug
|
||||
const real: MunicipalityPotential = computeMunicipalityPotential(realSlug)
|
||||
|
|
@ -216,7 +294,6 @@ describe('computeMunicipalityPotential / computeAllMunicipalityPotentials (real
|
|||
|
||||
it('computeAllMunicipalityPotentials covers exactly the municipality catalog', async () => {
|
||||
const { computeAllMunicipalityPotentials } = await import('@/utilities/municipalityPotential')
|
||||
const { municipalityCatalog } = await import('@/lib/municipalityCatalog')
|
||||
|
||||
const potentials = computeAllMunicipalityPotentials()
|
||||
expect(potentials).toHaveLength(municipalityCatalog.length)
|
||||
|
|
|
|||
|
|
@ -5,8 +5,13 @@ import {
|
|||
buildMunicipalitySortHref,
|
||||
formatMunicipalityConcentrationHint,
|
||||
formatMunicipalityListSortSummary,
|
||||
formatMunicipalitySignalAgeLabel,
|
||||
isMunicipalitySignalCold,
|
||||
MUNICIPALITY_COLD_SIGNAL_DAYS,
|
||||
municipalitySignalAgeInDays,
|
||||
parseMunicipalityListParams,
|
||||
parseMunicipalitySortValue,
|
||||
resolveMunicipalityLastSignalAt,
|
||||
resolveMunicipalityListSort,
|
||||
serializeMunicipalitySortValue,
|
||||
shouldUpdateMunicipalitySearchUrl,
|
||||
|
|
@ -25,20 +30,35 @@ describe('shouldUpdateMunicipalitySearchUrl', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('municipality list sort params (A11 + B15)', () => {
|
||||
it('omits default votos/desc from the URL (mesa order)', () => {
|
||||
const state = parseMunicipalityListParams({ sort: 'votos', dir: 'desc' })
|
||||
expect(state).toMatchObject({ sort: 'votos', dir: 'desc' })
|
||||
expect(resolveMunicipalityListSort(state)).toEqual({ sort: 'votos', dir: 'desc' })
|
||||
describe('municipality list sort params (A11 + B15 + E9)', () => {
|
||||
it('omits the default deficit/desc from the URL (E9 allocation queue order)', () => {
|
||||
const state = parseMunicipalityListParams({ sort: 'deficit', dir: 'desc' })
|
||||
expect(state).toMatchObject({ sort: 'deficit', dir: 'desc' })
|
||||
expect(resolveMunicipalityListSort(state)).toEqual({ sort: 'deficit', dir: 'desc' })
|
||||
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios')
|
||||
})
|
||||
|
||||
it('treats an empty query as votos desc', () => {
|
||||
it('treats an empty query as deficit desc (biggest uncovered goal first)', () => {
|
||||
const state = parseMunicipalityListParams({})
|
||||
expect(resolveMunicipalityListSort(state)).toEqual({ sort: 'votos', dir: 'desc' })
|
||||
expect(resolveMunicipalityListSort(state)).toEqual({ sort: 'deficit', dir: 'desc' })
|
||||
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios')
|
||||
})
|
||||
|
||||
it('keeps sort=votos in the URL now that it is no longer the default', () => {
|
||||
const state = parseMunicipalityListParams({ sort: 'votos', dir: 'desc' })
|
||||
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios?sort=votos')
|
||||
})
|
||||
|
||||
it('opens frescor on the coldest signal (desc default) and keeps asc explicit', () => {
|
||||
const base = parseMunicipalityListParams({})
|
||||
expect(buildMunicipalitySortHref(base, 'frescor')).toBe('/campanha/municipios?sort=frescor')
|
||||
const cold = parseMunicipalityListParams({ sort: 'frescor' })
|
||||
expect(resolveMunicipalityListSort(cold)).toEqual({ sort: 'frescor', dir: 'desc' })
|
||||
expect(buildMunicipalitySortHref(cold, 'frescor')).toBe(
|
||||
'/campanha/municipios?sort=frescor&dir=asc',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps sort=name when sorting alphabetically (dir=asc is the name default)', () => {
|
||||
const state = parseMunicipalityListParams({ sort: 'name', dir: 'asc' })
|
||||
expect(state.sort).toBe('name')
|
||||
|
|
@ -52,15 +72,15 @@ describe('municipality list sort params (A11 + B15)', () => {
|
|||
expect(buildMunicipalityListHref(state, 1)).toBe('/campanha/municipios?sort=votos&dir=asc')
|
||||
})
|
||||
|
||||
it('toggles votos from default desc to asc', () => {
|
||||
it('toggles deficit from the default desc to asc', () => {
|
||||
const base = parseMunicipalityListParams({})
|
||||
const href = buildMunicipalitySortHref(base, 'votos')
|
||||
expect(href).toBe('/campanha/municipios?sort=votos&dir=asc')
|
||||
const href = buildMunicipalitySortHref(base, 'deficit')
|
||||
expect(href).toBe('/campanha/municipios?sort=deficit&dir=asc')
|
||||
})
|
||||
|
||||
it('switches from name to votos with default desc (omitted URL)', () => {
|
||||
it('switches from name back to the default deficit desc (omitted URL)', () => {
|
||||
const base = parseMunicipalityListParams({ sort: 'name' })
|
||||
expect(buildMunicipalitySortHref(base, 'votos')).toBe('/campanha/municipios')
|
||||
expect(buildMunicipalitySortHref(base, 'deficit')).toBe('/campanha/municipios')
|
||||
})
|
||||
|
||||
it('parses mobile Ordenar values with | and rejects junk', () => {
|
||||
|
|
@ -78,6 +98,12 @@ describe('municipality list sort params (A11 + B15)', () => {
|
|||
expect(formatMunicipalityListSortSummary('region', 'asc')).toBe(
|
||||
'Ordenado por Território de identidade ↑',
|
||||
)
|
||||
expect(formatMunicipalityListSortSummary('deficit', 'desc')).toBe(
|
||||
'Ordenado por déficit da meta (maior primeiro)',
|
||||
)
|
||||
expect(formatMunicipalityListSortSummary('frescor', 'desc')).toBe(
|
||||
'Ordenado por frescor (sinal mais frio primeiro)',
|
||||
)
|
||||
})
|
||||
|
||||
it('formats the concentração header hint once for tooltip/caption', () => {
|
||||
|
|
@ -87,3 +113,48 @@ describe('municipality list sort params (A11 + B15)', () => {
|
|||
expect(formatMunicipalityConcentrationHint(12)).toMatch(/12/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('E9 frescor do sinal', () => {
|
||||
const now = new Date('2026-07-24T12:00:00.000Z')
|
||||
|
||||
it('takes the latest of the staff update and the pledge dates', () => {
|
||||
expect(
|
||||
resolveMunicipalityLastSignalAt('2026-07-01T00:00:00.000Z', '2026-07-10T00:00:00.000Z'),
|
||||
).toBe('2026-07-10T00:00:00.000Z')
|
||||
expect(
|
||||
resolveMunicipalityLastSignalAt('2026-07-20T00:00:00.000Z', '2026-07-10T00:00:00.000Z'),
|
||||
).toBe('2026-07-20T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('falls back to whichever side exists, and stays null when neither does', () => {
|
||||
expect(resolveMunicipalityLastSignalAt(null, '2026-07-10T00:00:00.000Z')).toBe(
|
||||
'2026-07-10T00:00:00.000Z',
|
||||
)
|
||||
expect(resolveMunicipalityLastSignalAt('2026-07-10T00:00:00.000Z', null)).toBe(
|
||||
'2026-07-10T00:00:00.000Z',
|
||||
)
|
||||
expect(resolveMunicipalityLastSignalAt(null, null)).toBeNull()
|
||||
})
|
||||
|
||||
it('floors the age in days and never goes negative on a future timestamp', () => {
|
||||
expect(municipalitySignalAgeInDays('2026-07-24T00:00:00.000Z', now)).toBe(0)
|
||||
expect(municipalitySignalAgeInDays('2026-07-23T00:00:00.000Z', now)).toBe(1)
|
||||
expect(municipalitySignalAgeInDays('2026-07-01T00:00:00.000Z', now)).toBe(23)
|
||||
expect(municipalitySignalAgeInDays('2026-08-01T00:00:00.000Z', now)).toBe(0)
|
||||
expect(municipalitySignalAgeInDays(null, now)).toBeNull()
|
||||
})
|
||||
|
||||
it('treats "no signal at all" as cold, alongside anything past the threshold', () => {
|
||||
expect(isMunicipalitySignalCold(null)).toBe(true)
|
||||
expect(isMunicipalitySignalCold(MUNICIPALITY_COLD_SIGNAL_DAYS)).toBe(true)
|
||||
expect(isMunicipalitySignalCold(MUNICIPALITY_COLD_SIGNAL_DAYS - 1)).toBe(false)
|
||||
expect(isMunicipalitySignalCold(0)).toBe(false)
|
||||
})
|
||||
|
||||
it('labels the age in dense pt-BR copy', () => {
|
||||
expect(formatMunicipalitySignalAgeLabel(null)).toBe('Sem sinal')
|
||||
expect(formatMunicipalitySignalAgeLabel(0)).toBe('hoje')
|
||||
expect(formatMunicipalitySignalAgeLabel(1)).toBe('há 1 dia')
|
||||
expect(formatMunicipalitySignalAgeLabel(30)).toBe('há 30 dias')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue