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(),
}
}