From 0ef4ddac1d61d0933bd25a2062356ebeac110900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sat, 25 Jul 2026 22:33:16 -0300 Subject: [PATCH] E10 /simplify: remove superficie morta, unifica o parser de enum repetivel e reusa o ranking A11 - campoVotes (sem leitor) e classification.fieldHeadroom (duplicava o fator field) removidos - formatTerritorialClassWhy passa a ser mesmo a fonte unica do slice(0,2) e do fallback "sem serie", eliminando o sentinela de string vazia nas duas superficies de detalhe - classe territorial vira um so componente com layout table|card, como os vizinhos - parseExhaustiveEnumParam em campaignListUrl cobre trend e class (o includes era morto: allParamValues ja deduplica) - bloco central caminha o computeVoteRankByYear memoizado do A11 em vez de reordenar os 435 slugs por conta propria - comentarios corrigidos onde afirmavam o contrario do codigo (CampaignCellTooltip e a regra do sem_base por superficie) --- AGENTS.md | 2 +- .../municipality/MunicipalityDossier.tsx | 3 +- .../MunicipalityGoalAccountCard.tsx | 3 +- .../municipality/MunicipalityList.tsx | 66 +++++++++---------- .../campaign/shared/CampaignCellTooltip.tsx | 6 +- .../campaign/shared/CampaignHoverTooltip.tsx | 7 +- src/lib/bahiaElectionAggregates.ts | 7 +- src/lib/electionFormat.ts | 11 ++-- src/utilities/campaignListUrl.ts | 15 +++++ src/utilities/municipalityLabels.ts | 38 +++++++---- src/utilities/municipalityListFilters.ts | 2 +- src/utilities/municipalityListUrl.ts | 36 +++------- src/utilities/municipalityPageData.ts | 20 +++--- src/utilities/municipalityTerritorialClass.ts | 42 +++++------- src/utilities/municipalityViewModels.ts | 4 +- 15 files changed, 126 insertions(+), 136 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c9907ccd..62acfa22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-25):** **C13** "Plano de Ação" → "Atividade" — the vertical speaks one word now, the one the mesa already uses. Collection `actionPlan` → `activity` (`src/collections/Activity.ts`, labels "Atividade"/"Atividades"), `campaignDemand.actionPlan` → `activity`, route `/campanha/planos` → `/campanha/atividades` (create at `/campanha/atividades/nova`), sidebar entry "Atividades", and field labels realigned to the entity ("Tipo de atividade", "Origem da atividade", "Resultado da atividade") — enum **values** (`kind`/`status`/`origin`) are unchanged. Schema moved by a **hand-written data-preserving migration** (`20260725_213000_rename_action_plan_to_activity`): 4 tables, 3 enums, 2 columns, 39 indexes (4 pkeys included — renaming a pkey index renames its constraint), 14 FKs and 2 sequences, every statement guarded on the source object existing so environments converge either way, with a symmetric `down()`. Payload's generator would have emitted DROP+CREATE (data loss) and cannot see the hand-written partial index `action_plan_upcoming_start_at_idx`, so the object list came from `pg_indexes`/`pg_constraint`/`pg_type`/`pg_sequences` and the `.json` snapshot was written by transforming its predecessor — validated by `pnpm migrate:create __probe` reporting no schema changes. The same migration aligned the **8 fossil `%plaza%` index names** the Município remodel left behind (it renamed the `plaza_id` columns but not their indexes, so the database and the snapshot had been disagreeing since 2026-07-23). Guard: the `codebaseConventions.unit.spec.ts` "banned campaign terminology" describe is now table-driven and sweeps `src` + `tests` + `scripts` (migrations stay out — frozen history), banning the `actionPlan` compounds, the pt-BR copy and the old route, but **not** bare `plan`/`plano` (that would collide with the white-label subscription plans of Fase 2); allowlist covers the spec itself, `scripts/generate-remodel-municipalities-migration.mjs` and the historical comment in `campaignMigrationReconciliation.int.spec.ts`. Plano: [`docs/plans/renomear-plano-acao-para-atividade.md`](docs/plans/renomear-plano-acao-para-atividade.md). -**Recently resolved (2026-07-25):** **E10** Classificação territorial relativa — `src/utilities/municipalityTerritorialClass.ts` classifies each of the 435 catalog municipalities as **`reduto | expansao | manutencao | marginal | sem_base`** (labels "Reduto · Expansão · Manutenção · Marginal · Sem base"). This vocabulary comes from the discovery research and **replaces the majoritarian-era `Defesa/Ataque/Indecisa/Perdida`** — don't reintroduce it. The classifier is a **pure function over the committed TSE artifact** (no DB, no `campaignGoals`, no `user`, no migration): location quotient against the candidate's own statewide standard (`TERRITORIAL_CLASS_ANCHORS.strongLq` 2 / `weakLq` 0.5), share of his own statewide vote, uncaptured field ceiling (E8's `projectedFieldCeiling`) compared against the **catalog median**, plus membership in the block holding 50% of his vote (which is what keeps a big low-penetration município out of "Marginal"). Cuts are ILLUSTRATIVE and versioned in one object — calibration is **E15**. `getStatewideFederalTotals(year)` was extracted and memoized into `bahiaElectionAggregates.ts`; `municipalityVoteRank.ts` reuses it instead of summing the state itself. **The UI never renders the label alone** (research §6.4 — a bare verdict buys false confidence): the class always travels with its two dominant factors as real text — never a `title` attribute, which reaches neither keyboard nor touch. In the list that text is `sr-only` behind the badge and shown on hover/focus/tap through the **B23 `cellTooltip` seam** (spelling it out under the pill made Classe the widest cell in a table that cannot scroll horizontally); on the detail card and the dossier it is a visible paragraph. Surfaces: `MunicipalityGoalAccountCard` (E8 — the class opens the diagnostics block it summarizes, no new card), the `MunicipalityDossier` cover (self-labeling badge "Classe reduto" + factors; print CSS forces `print-color-adjust: exact` on `[data-slot='badge']`, otherwise the printer flattens priority/trend/class into identical grey), and a new **"Classe" column** in `/campanha/municipios` right after "2022" — the 10th staff column, accepted without B17 by explicit product decision. Badge palette is documented in `DESIGN.md` § Status Badge; `sem_base` deliberately renders as an em dash, not a badge. **List filtering is the one derived filter in the system:** `state.classes` is NOT part of `buildMunicipalityListWhere` — `municipalityPageData.ts` forces the `limit: 0, pagination: false` path whenever a class is selected (otherwise a 25-row page would drop rows and `totalDocs` would lie) and applies the same predicate to `staffScope.municipalities` before the overview rollup and to all three column facets. URL: repeatable `?class=` (all selected canonicalizes to absent, same as `trend`) and sort key `classe` (ordinal via `territorialClassSortWeight`, `desc` default = reduto first, `sem_base` last either way). Two new `diagnostico` concepts in `/campanha/conceitos` (E18). NEC/competition stayed out of v1 (would need a new SQL aggregate; trigger is E11 needing the competition axis beyond the intra-field proxy). Unblocks **B13**, which should consume `computeMunicipalityTerritorialClass` as-is rather than reclassify. Plano: [`docs/plans/classificacao-territorial-relativa.md`](docs/plans/classificacao-territorial-relativa.md). +**Recently resolved (2026-07-25):** **E10** Classificação territorial relativa — `src/utilities/municipalityTerritorialClass.ts` classifies each of the 435 catalog municipalities as **`reduto | expansao | manutencao | marginal | sem_base`** (labels "Reduto · Expansão · Manutenção · Marginal · Sem base"). This vocabulary comes from the discovery research and **replaces the majoritarian-era `Defesa/Ataque/Indecisa/Perdida`** — don't reintroduce it. The classifier is a **pure function over the committed TSE artifact** (no DB, no `campaignGoals`, no `user`, no migration): location quotient against the candidate's own statewide standard (`TERRITORIAL_CLASS_ANCHORS.strongLq` 2 / `weakLq` 0.5), share of his own statewide vote, uncaptured field ceiling (E8's `projectedFieldCeiling`) compared against the **catalog median**, plus membership in the block holding 50% of his vote (which is what keeps a big low-penetration município out of "Marginal") — that block is built by walking A11's already-memoized `computeVoteRankByYear` ordering, not by re-sorting the catalog. Cuts are ILLUSTRATIVE and versioned in one object — calibration is **E15**. `getStatewideFederalTotals(year)` was extracted and memoized into `bahiaElectionAggregates.ts`; `municipalityVoteRank.ts` reuses it instead of summing the state itself. **The UI never renders the label alone** (research §6.4 — a bare verdict buys false confidence): the class always travels with its two dominant factors as real text — never a `title` attribute, which reaches neither keyboard nor touch. In the list that text is `sr-only` behind the badge and shown on hover/focus/tap through the **B23 `cellTooltip` seam** (spelling it out under the pill made Classe the widest cell in a table that cannot scroll horizontally); on the detail card and the dossier it is a visible paragraph. Surfaces: `MunicipalityGoalAccountCard` (E8 — the class opens the diagnostics block it summarizes, no new card), the `MunicipalityDossier` cover (self-labeling badge "Classe reduto" + factors; print CSS forces `print-color-adjust: exact` on `[data-slot='badge']`, otherwise the printer flattens priority/trend/class into identical grey), and a new **"Classe" column** in `/campanha/municipios` right after "2022" — the 10th staff column, accepted without B17 by explicit product decision. Badge palette is documented in `DESIGN.md` § Status Badge; `sem_base` deliberately renders as an em dash in the list (matching the neighboring "2022" column) and keeps the flattest pill on the detail card and the dossier, where it sits in a labeled "Classe …" field. **List filtering is the one derived filter in the system:** `state.classes` is NOT part of `buildMunicipalityListWhere` — `municipalityPageData.ts` forces the `limit: 0, pagination: false` path whenever a class is selected (otherwise a 25-row page would drop rows and `totalDocs` would lie) and applies the same predicate to `staffScope.municipalities` before the overview rollup and to all three column facets. URL: repeatable `?class=` (all selected canonicalizes to absent, same as `trend`) and sort key `classe` (ordinal via `territorialClassSortWeight`, `desc` default = reduto first, `sem_base` last either way). Two new `diagnostico` concepts in `/campanha/conceitos` (E18). NEC/competition stayed out of v1 (would need a new SQL aggregate; trigger is E11 needing the competition axis beyond the intra-field proxy). Unblocks **B13**, which should consume `computeMunicipalityTerritorialClass` as-is rather than reclassify. Plano: [`docs/plans/classificacao-territorial-relativa.md`](docs/plans/classificacao-territorial-relativa.md). **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). diff --git a/src/components/campaign/municipality/MunicipalityDossier.tsx b/src/components/campaign/municipality/MunicipalityDossier.tsx index 2fb2d039..e56651d3 100644 --- a/src/components/campaign/municipality/MunicipalityDossier.tsx +++ b/src/components/campaign/municipality/MunicipalityDossier.tsx @@ -24,7 +24,6 @@ import { municipalityPriorityLabels, politicalTrendBadgeVariant, politicalTrendLabels, - TERRITORIAL_CLASS_NO_DATA, territorialClassBadgeVariant, territorialClassLabels, } from '@/utilities/municipalityLabels' @@ -110,7 +109,7 @@ export const MunicipalityDossier = ({ const goalAccount = data.goalAccount const territorialClass = goalAccount?.territorialClass ?? null const territorialClassWhy = territorialClass - ? formatTerritorialClassWhy(territorialClass.factors) || TERRITORIAL_CLASS_NO_DATA + ? formatTerritorialClassWhy(territorialClass.factors) : null const usesMesaEstimate = strategy?.expectedVotes.central != null diff --git a/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx b/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx index 9dc65739..68c93004 100644 --- a/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx +++ b/src/components/campaign/municipality/MunicipalityGoalAccountCard.tsx @@ -22,7 +22,6 @@ import { } from '@/utilities/goalCoverage' import { formatTerritorialClassWhy, - TERRITORIAL_CLASS_NO_DATA, territorialClassBadgeVariant, territorialClassLabels, } from '@/utilities/municipalityLabels' @@ -154,7 +153,7 @@ const TerritorialClassRow = ({ -

{why || TERRITORIAL_CLASS_NO_DATA}

+

{why}

) } diff --git a/src/components/campaign/municipality/MunicipalityList.tsx b/src/components/campaign/municipality/MunicipalityList.tsx index f3401901..242ad76d 100644 --- a/src/components/campaign/municipality/MunicipalityList.tsx +++ b/src/components/campaign/municipality/MunicipalityList.tsx @@ -34,7 +34,6 @@ import { formatTerritorialClassWhy, municipalityKindLabels, municipalityPriorityLabels, - TERRITORIAL_CLASS_NO_DATA, territorialClassBadgeVariant, territorialClassLabels, } from '@/utilities/municipalityLabels' @@ -114,21 +113,36 @@ const VotePositionReadout = ({ ) } +const CLASS_COLUMN_HINT = + 'Leitura relativa de 2022: o desempenho aqui contra o padrão estadual do próprio candidato. Reduto (bem acima do padrão), Expansão (abaixo, mas com campo a ocupar), Manutenção (no padrão), Marginal (abaixo, com pouco campo) e — sem série do TSE. É sugestão de leitura, não decisão: cada classe traz o porquê ao passar o mouse ou tocar nela.' + /** - * E10 classe. Only the pill is visible — spelling the factors out under it - * made this the widest cell in a table that cannot scroll horizontally. The - * "por quê" moves to the column's `cellTooltip` (hover, focus and tap), and - * stays in the cell as `sr-only` text so the class still never reaches anyone - * as a bare verdict, which is the one thing it must never be. + * E10 classe. In the table only the pill is visible — spelling the factors out + * under it made this the widest cell in a grid that cannot scroll + * horizontally, so the "por quê" moves to the column's `cellTooltip` (hover, + * focus and tap) and stays as `sr-only` text; the card has the width to show + * it outright. Either way the class never reaches anyone as a bare verdict, + * which is the one thing it must never be. */ -const TerritorialClassCell = ({ municipality }: { municipality: MunicipalityListViewModel }) => { +const TerritorialClassReadout = ({ + municipality, + layout, +}: { + municipality: MunicipalityListViewModel + layout: 'table' | 'card' +}) => { + const isTable = layout === 'table' + const why = formatTerritorialClassWhy(municipality.territorialClassFactors) + if (municipality.territorialClass === 'sem_base') { // Same idiom as the "2022" column: absent data is a dash, not a pill. - return ( + return isTable ? ( - {TERRITORIAL_CLASS_NO_DATA} + {why} + ) : ( + {why} ) } @@ -137,9 +151,7 @@ const TerritorialClassCell = ({ municipality }: { municipality: MunicipalityList {territorialClassLabels[municipality.territorialClass]} - - {formatTerritorialClassWhy(municipality.territorialClassFactors)} - + {why} ) } @@ -247,12 +259,10 @@ const municipalityListColumns = ({ concentrationHint, signalHint, deficitHint, - classHint, }: MunicipalityListProps & { concentrationHint: string signalHint: string deficitHint: string - classHint: string }): Array> => [ { id: 'name', @@ -336,12 +346,14 @@ const municipalityListColumns = ({ state={state} sortKey="classe" filterParam="class" - tooltip={classHint} + tooltip={CLASS_COLUMN_HINT} > Classe ), - cell: (municipality) => , + cell: (municipality) => ( + + ), // The factors behind the label — the cell keeps them as `sr-only` // text, so this stays a redundant affordance (see `cellTooltip`). cellTooltip: (municipality) => @@ -482,15 +494,11 @@ export const MunicipalityList = (props: MunicipalityListProps) => { // 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.` - const classHint = - 'Leitura relativa de 2022: o desempenho aqui contra o padrão estadual do próprio candidato. Reduto (bem acima do padrão), Expansão (abaixo, mas com campo a ocupar), Manutenção (no padrão), Marginal (abaixo, com pouco campo) e — sem série do TSE. É sugestão de leitura, não decisão: cada classe traz o porquê ao passar o mouse ou tocar nela.' - const columns = municipalityListColumns({ ...props, concentrationHint, signalHint, deficitHint, - classHint, }) return ( @@ -521,23 +529,11 @@ export const MunicipalityList = (props: MunicipalityListProps) => { {isStaffView ? (
- {/* The card has room for both factors the table trims to one. */}
Classe
- {municipality.territorialClass === 'sem_base' ? ( -
{TERRITORIAL_CLASS_NO_DATA}
- ) : ( -
- - {territorialClassLabels[municipality.territorialClass]} - - - {formatTerritorialClassWhy(municipality.territorialClassFactors)} - -
- )} +
+ +
Votos estimados
diff --git a/src/components/campaign/shared/CampaignCellTooltip.tsx b/src/components/campaign/shared/CampaignCellTooltip.tsx index 3efd3523..b8421d99 100644 --- a/src/components/campaign/shared/CampaignCellTooltip.tsx +++ b/src/components/campaign/shared/CampaignCellTooltip.tsx @@ -9,9 +9,9 @@ import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHover * `CampaignTableColumn.cellTooltip`. Columns declare the extra reading; the * table wraps the cell in this. * - * The trigger `` is created HERE, inside the client boundary, because - * `CampaignHoverTooltip` clones its child to attach a ref: an element built by - * the server component that owns the table would not survive that. + * The trigger `` is created HERE rather than by the column, so a column + * declares only the data (`cellTooltip`) and the table owns the affordance — + * every cell tooltip in the system then behaves and is sized identically. * * It deliberately takes no `tabIndex` — 435 rows × N columns of dead tab stops * would be a worse trade than the redundancy contract documented on diff --git a/src/components/campaign/shared/CampaignHoverTooltip.tsx b/src/components/campaign/shared/CampaignHoverTooltip.tsx index d6d665d1..1d99cdda 100644 --- a/src/components/campaign/shared/CampaignHoverTooltip.tsx +++ b/src/components/campaign/shared/CampaignHoverTooltip.tsx @@ -10,10 +10,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp * heading or table cell. Shared by the campaign list system: column headers * (`MunicipalitySortableHead`), card metrics, and cell content via * `CampaignTable`'s `cellTooltip`. Radix's `TooltipTrigger` deliberately - * ignores touch pointer - * events — no hover on touch, and a tap doesn't reliably focus a non-form - * element either — which otherwise makes the whole tooltip unreachable on - * phones (an `/impeccable critique` finding). The open state is controlled + * ignores touch pointer events — no hover on touch, and a tap doesn't + * reliably focus a non-form element either — which otherwise makes the whole + * tooltip unreachable on phones (an `/impeccable critique` finding). The open state is controlled * so a `pointerType === 'touch'` tap can explicitly toggle it; mouse hover * and keyboard focus are untouched (Radix keeps driving `onOpenChange` for * those exactly as before), so a **mouse** click — which already hovered the diff --git a/src/lib/bahiaElectionAggregates.ts b/src/lib/bahiaElectionAggregates.ts index 0af9fba7..1c1c4b8b 100644 --- a/src/lib/bahiaElectionAggregates.ts +++ b/src/lib/bahiaElectionAggregates.ts @@ -80,13 +80,11 @@ export const getMunicipalityFederalBaseline = (slug: string): MunicipalityFedera artifact.municipalities[slug] ?? EMPTY_BASELINE /** Statewide sums of the artifact for one year — the denominators of every relative reading. */ -export type StatewideFederalTotals = { +type StatewideFederalTotals = { /** Candidate's own nominal votes across every catalog municipality. */ ownVotes: number /** Federal T1 valid votes across every catalog municipality. */ validVotes: number - /** Curated campo-parties federal nominal votes across every catalog municipality. */ - campoVotes: number } const statewideTotalsCache = new Map() @@ -101,11 +99,10 @@ export const getStatewideFederalTotals = (year: number): StatewideFederalTotals if (cached) return cached const yearKey = String(year) - const totals: StatewideFederalTotals = { ownVotes: 0, validVotes: 0, campoVotes: 0 } + const totals: StatewideFederalTotals = { ownVotes: 0, validVotes: 0 } for (const baseline of Object.values(artifact.municipalities)) { totals.ownVotes += baseline.votesByYear[yearKey] ?? 0 totals.validVotes += baseline.validVotesByYear[yearKey] ?? 0 - totals.campoVotes += baseline.campoFederalVotesByYear[yearKey] ?? 0 } statewideTotalsCache.set(year, totals) diff --git a/src/lib/electionFormat.ts b/src/lib/electionFormat.ts index 7b75fd34..da3debfd 100644 --- a/src/lib/electionFormat.ts +++ b/src/lib/electionFormat.ts @@ -6,6 +6,12 @@ */ const numberFormatter = new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }) +/** Shares and multiples: one decimal at most, never a forced trailing zero. */ +export const oneDecimalFormatter = new Intl.NumberFormat('pt-BR', { + maximumFractionDigits: 1, + minimumFractionDigits: 0, +}) + export const formatElectionNumber = (value: number): string => numberFormatter.format(value) /** @@ -15,7 +21,4 @@ export const formatElectionNumber = (value: number): string => numberFormatter.f * committed TSE artifact along with it. */ export const formatVoteSharePercent = (share: number): string => - `${(share * 100).toLocaleString('pt-BR', { - maximumFractionDigits: 1, - minimumFractionDigits: 0, - })}%` + `${oneDecimalFormatter.format(share * 100)}%` diff --git a/src/utilities/campaignListUrl.ts b/src/utilities/campaignListUrl.ts index e1a20c78..d809988d 100644 --- a/src/utilities/campaignListUrl.ts +++ b/src/utilities/campaignListUrl.ts @@ -15,6 +15,21 @@ export const allParamValues = (value: string | string[] | undefined): string[] = return [...seen] } +/** + * A repeatable enum param where selecting EVERY member means "todas" — which + * is the same filter as selecting none, and canonicalizes to the absent param + * so both produce one URL (B18's frozen contract). `allParamValues` already + * dropped duplicates and unknown tokens are ignored, so a hand-typed URL can + * never widen the filter. + */ +export const parseExhaustiveEnumParam = ( + raw: string | string[] | undefined, + allowed: ReadonlySet, +): T[] => { + const values = allParamValues(raw).filter((token): token is T => allowed.has(token)) + return values.length < allowed.size ? values : [] +} + export const normalizedText = (value: string | undefined): string | undefined => { if (typeof value !== 'string') return undefined const normalized = value.trim() diff --git a/src/utilities/municipalityLabels.ts b/src/utilities/municipalityLabels.ts index e0357da5..1230c7a6 100644 --- a/src/utilities/municipalityLabels.ts +++ b/src/utilities/municipalityLabels.ts @@ -1,9 +1,15 @@ /** * Municipality display labels shared by list, detail, dossier and map - * surfaces. Client-safe: types + string tables only (split out of the former - * `municipalityUi.ts` in Pass 2 W1). + * surfaces. Client-safe: string tables plus the pure formatters that turn a + * derived value into its pt-BR copy — no Payload, no DB, and no value import + * from a `server-only` module (split out of the former `municipalityUi.ts` in + * Pass 2 W1). */ -import { formatElectionNumber, formatVoteSharePercent } from '@/lib/electionFormat' +import { + formatElectionNumber, + formatVoteSharePercent, + oneDecimalFormatter, +} from '@/lib/electionFormat' import { municipalityCatalog } from '@/lib/municipalityCatalog' import type { CampaignUser, Municipality } from '@/payload-types' import { formatRatioAsPercentLabel } from '@/utilities/goalCoverage' @@ -58,9 +64,13 @@ export const territorialClassLabels: Record { return 'no padrão estadual do candidato' } return factor.value >= 1 - ? `${factor.value.toLocaleString('pt-BR', { maximumFractionDigits: 1 })}× o padrão estadual do candidato` - : `${Math.round((1 - factor.value) * 100)}% abaixo do padrão estadual do candidato` + ? `${oneDecimalFormatter.format(factor.value)}× o padrão estadual do candidato` + : `${formatRatioAsPercentLabel(1 - factor.value)} abaixo do padrão estadual do candidato` case 'ownShare': return `${formatVoteSharePercent(factor.value)} da votação estadual do candidato` case 'field': @@ -98,14 +108,18 @@ const formatTerritorialClassFactor = (factor: TerritorialFactor): string => { } /** Copy for "no TSE series here" — the class label alone would read as a bug. */ -export const TERRITORIAL_CLASS_NO_DATA = 'Sem série do TSE para este município.' +const TERRITORIAL_CLASS_NO_DATA = 'Sem série do TSE para este município.' /** - * The two dominant factors as one line. Single source for the rule (list cell, - * detail card and printed dossier all showed the same `slice(0, 2)` before). + * The two dominant factors as one line — single source for the rule, which the + * list cell, the detail card and the printed dossier each used to carry. Falls + * back to the "no series" copy so a caller can never render a blank line where + * the "por quê" belongs. */ export const formatTerritorialClassWhy = (factors: readonly TerritorialFactor[]): string => - factors.slice(0, 2).map(formatTerritorialClassFactor).join(' · ') + factors.length === 0 + ? TERRITORIAL_CLASS_NO_DATA + : factors.slice(0, 2).map(formatTerritorialClassFactor).join(' · ') export const formatMunicipalityConcentrationHint = ( totalUnits: number = municipalityCatalog.length, diff --git a/src/utilities/municipalityListFilters.ts b/src/utilities/municipalityListFilters.ts index 57559d22..95957ac9 100644 --- a/src/utilities/municipalityListFilters.ts +++ b/src/utilities/municipalityListFilters.ts @@ -277,7 +277,7 @@ export const buildMunicipalityFilterOptionHref = ( const trends = toggled.filter(isPoliticalTrendStatus) if (trends.length && trends.length < trendStatusCount) next.trends = trends else delete next.trends - } else { + } else if (param === 'class') { const classes = toggled.filter(isTerritorialClass) if (classes.length && classes.length < territorialClassCount) next.classes = classes else delete next.classes diff --git a/src/utilities/municipalityListUrl.ts b/src/utilities/municipalityListUrl.ts index 5de4bbe3..ef8dc73f 100644 --- a/src/utilities/municipalityListUrl.ts +++ b/src/utilities/municipalityListUrl.ts @@ -14,6 +14,7 @@ import { buildListHref, firstValue, normalizedText, + parseExhaustiveEnumParam, resolveListUrl, strictDecimalInteger, type RawSearchParams as CampaignListRawSearchParams, @@ -217,33 +218,8 @@ const parseAdvisorsParam = (raw: string | string[] | undefined): number[] => { } const politicalTrendStatusSet = new Set(Object.keys(politicalTrendLabels)) - -/** Selecting every trend means "todas", which is the same thing as selecting none. */ -const parseTrendsParam = (raw: string | string[] | undefined): PoliticalTrendStatus[] => { - const trends: PoliticalTrendStatus[] = [] - for (const token of allParamValues(raw)) { - if (!politicalTrendStatusSet.has(token)) continue - const trend = token as PoliticalTrendStatus - if (trends.includes(trend)) continue - trends.push(trend) - } - return trends.length < politicalTrendStatusSet.size ? trends : [] -} - const territorialClassSet = new Set(Object.keys(territorialClassLabels)) -/** Same "todas = nenhuma" canonicalization as `parseTrendsParam`. */ -const parseClassesParam = (raw: string | string[] | undefined): MunicipalityTerritorialClass[] => { - const classes: MunicipalityTerritorialClass[] = [] - for (const token of allParamValues(raw)) { - if (!territorialClassSet.has(token)) continue - const territorialClass = token as MunicipalityTerritorialClass - if (classes.includes(territorialClass)) continue - classes.push(territorialClass) - } - return classes.length < territorialClassSet.size ? classes : [] -} - export const municipalityListStateToRawParams = ( state: MunicipalityListState, page = state.page, @@ -274,8 +250,14 @@ export const parseMunicipalityListParams = ( const rawKind = firstValue(params.kind) const rawCoverage = firstValue(params.coverage) const rawPriority = firstValue(params.priority) - const trends = parseTrendsParam(params.trend) - const classes = parseClassesParam(params.class) + const trends = parseExhaustiveEnumParam( + params.trend, + politicalTrendStatusSet, + ) + const classes = parseExhaustiveEnumParam( + params.class, + territorialClassSet, + ) const rawCompare = strictDecimalInteger(firstValue(params.compare)) const rawSort = firstValue(params.sort) as MunicipalityListSortKey | undefined const sort = rawSort && municipalityListSortKeySet.has(rawSort) ? rawSort : undefined diff --git a/src/utilities/municipalityPageData.ts b/src/utilities/municipalityPageData.ts index d74fcd6f..5c4e3194 100644 --- a/src/utilities/municipalityPageData.ts +++ b/src/utilities/municipalityPageData.ts @@ -126,9 +126,15 @@ const loadMunicipalityListFilterFacets = async ( /** The scope read is a PROMISE so the facets can join the page's main `Promise.all` (B16+). */ loadedScope: { where: Where; rows: Promise } | null, ): Promise => { + // No facet popover owns the class filter, so every facet read keeps it + // applied — inside the memo, so a `where` shared by two facets is filtered once. + const classMatches = territorialClassFilterPredicate(state) + const applyClassFilter = (rows: MunicipalityFacetRow[]): MunicipalityFacetRow[] => + classMatches ? rows.filter((row) => classMatches(row.slug)) : rows + const rowsByWhere = new Map>() if (loadedScope) { - rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows) + rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows.then(applyClassFilter)) } const facetRows = (omit: Partial): Promise => { const where = buildMunicipalityListWhere({ ...state, ...omit }) @@ -147,24 +153,16 @@ const loadMunicipalityListFilterFacets = async ( user, overrideAccess: false, }) - .then((result) => result.docs as MunicipalityFacetRow[]) + .then((result) => applyClassFilter(result.docs as MunicipalityFacetRow[])) rowsByWhere.set(key, rows) return rows } - // No facet popover owns the class filter, so all three keep it applied. - const classMatches = territorialClassFilterPredicate(state) - const withClassFilter = (rows: MunicipalityFacetRow[]): MunicipalityFacetRow[] => - classMatches ? rows.filter((row) => classMatches(row.slug)) : rows - - const [rawSlugRows, rawRegionRows, rawAdvisorRows] = await Promise.all([ + const [slugRows, regionRows, advisorRows] = await Promise.all([ facetRows({ slugs: undefined, priority: undefined }), facetRows({ regions: undefined }), facetRows({ advisors: undefined, coverage: undefined }), ]) - const slugRows = withClassFilter(rawSlugRows) - const regionRows = withClassFilter(rawRegionRows) - const advisorRows = withClassFilter(rawAdvisorRows) // Selected values are unioned in: a selection must stay visible to be undone. const availableSlugs = new Set([...slugRows.map((row) => row.slug), ...(state.slugs ?? [])]) diff --git a/src/utilities/municipalityTerritorialClass.ts b/src/utilities/municipalityTerritorialClass.ts index 5c969b75..b0632277 100644 --- a/src/utilities/municipalityTerritorialClass.ts +++ b/src/utilities/municipalityTerritorialClass.ts @@ -5,6 +5,7 @@ import { type MunicipalityFederalBaseline, } from '@/lib/bahiaElectionAggregates' import { ELECTION_YEAR_2022 } from '@/lib/electionResults' +import { computeVoteRankByYear } from '@/lib/municipalityVoteRank' import { captureRate, ownVotes2022, projectedFieldCeiling } from '@/utilities/municipalityPotential' /** @@ -71,8 +72,6 @@ export type MunicipalityTerritorialClassification = { lq: number | null /** Share of his statewide vote that comes from here (the mesa's own anchor, A11). */ ownShare: number - /** Field votes (projected 2022 ceiling) his candidacy did not capture. */ - fieldHeadroom: number /** True when the município belongs to the block holding `coreCumulativeShare` of his vote. */ inCoreBlock: boolean } @@ -95,7 +94,6 @@ const UNCLASSIFIED: MunicipalityTerritorialClassification = { factors: [], lq: null, ownShare: 0, - fieldHeadroom: 0, inCoreBlock: false, } @@ -114,12 +112,7 @@ export const classifyMunicipalityTerritory = ( const stateShare = stateOwnVotes / stateValidVotes const lq = ownVotes / validVotes / stateShare const ownShare = ownVotes / stateOwnVotes - const shared = { - lq, - ownShare, - fieldHeadroom: input.fieldHeadroom, - inCoreBlock: input.inCoreBlock, - } + const shared = { lq, ownShare, inCoreBlock: input.inCoreBlock } const dominance: TerritorialFactor = { id: 'dominance', value: lq } const ownShareFactor: TerritorialFactor = { id: 'ownShare', value: ownShare } const fieldFactor: TerritorialFactor = { id: 'field', value: input.fieldHeadroom } @@ -173,31 +166,26 @@ let catalogContext: CatalogContext | null = null const getCatalogContext = (): CatalogContext => { if (catalogContext) return catalogContext - const rows = federalBaselineMunicipalitySlugs().map((slug) => { - const baseline = getMunicipalityFederalBaseline(slug) - return { - slug, - ownVotes: ownVotes2022(baseline), - fieldHeadroom: fieldHeadroomOf(baseline), - hasFieldData: projectedFieldCeiling(baseline) > 0, - } - }) - // Slugs whose majoritarian cell is missing would drag the median to zero and // make every weak município read as "expansão". const medianFieldHeadroom = median( - rows.filter((row) => row.hasFieldData).map((row) => row.fieldHeadroom), + federalBaselineMunicipalitySlugs() + .map(getMunicipalityFederalBaseline) + .filter((baseline) => projectedFieldCeiling(baseline) > 0) + .map(fieldHeadroomOf), ) - const stateOwnVotes = getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR).ownVotes + // A11 already ranks every slug by own vote, descending, and memoizes it — + // walking that map is the same order this used to re-sort for itself. + const coreVoteTarget = + getStatewideFederalTotals(TERRITORIAL_CLASS_YEAR).ownVotes * + TERRITORIAL_CLASS_ANCHORS.coreCumulativeShare const coreBlockSlugs = new Set() let accumulated = 0 - for (const row of [...rows].sort( - (a, b) => b.ownVotes - a.ownVotes || a.slug.localeCompare(b.slug), - )) { - if (accumulated >= stateOwnVotes * TERRITORIAL_CLASS_ANCHORS.coreCumulativeShare) break - coreBlockSlugs.add(row.slug) - accumulated += row.ownVotes + for (const [slug, entry] of computeVoteRankByYear(TERRITORIAL_CLASS_YEAR)) { + if (accumulated >= coreVoteTarget) break + coreBlockSlugs.add(slug) + accumulated += entry.votes } catalogContext = { medianFieldHeadroom, coreBlockSlugs } diff --git a/src/utilities/municipalityViewModels.ts b/src/utilities/municipalityViewModels.ts index 94237b40..e8c76a1c 100644 --- a/src/utilities/municipalityViewModels.ts +++ b/src/utilities/municipalityViewModels.ts @@ -76,7 +76,7 @@ export type MunicipalityListViewModel = { votePosition2022: MunicipalityVoteRankEntry | null /** E10 — classe operacional derivada do artefato TSE. */ territorialClass: MunicipalityTerritorialClass - /** Os dois fatores que produziram a classe: a célula nunca mostra o rótulo sozinho. */ + /** Os fatores que produziram a classe: a célula nunca mostra o rótulo sozinho. */ territorialClassFactors: TerritorialFactor[] /** E8 "conta da cadeira" — meta × comprometido por cenário; null fora da staff view. */ goalCoverageByScenario: Record @@ -114,7 +114,7 @@ export const toMunicipalityListViewModel = ( pledges: pledges ?? createEmptyMunicipalityPledgeAggregate(), votePosition2022, territorialClass: territorialClass.class, - territorialClassFactors: territorialClass.factors.slice(0, 2), + territorialClassFactors: territorialClass.factors, goalCoverageByScenario: goalCoverageByScenario ?? createEmptyGoalCoverageByScenario(), } }