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)
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-25 22:33:16 -03:00
parent 63fe4431bf
commit 0ef4ddac1d
15 changed files with 126 additions and 136 deletions

View file

@ -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).

View file

@ -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

View file

@ -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 = ({
</Badge>
</button>
</CampaignHoverTooltip>
<p className="px-1.5 text-xs text-muted-foreground">{why || TERRITORIAL_CLASS_NO_DATA}</p>
<p className="px-1.5 text-xs text-muted-foreground">{why}</p>
</div>
)
}

View file

@ -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 ? (
<span className="text-muted-foreground">
<span aria-hidden="true">—</span>
<span className="sr-only">{TERRITORIAL_CLASS_NO_DATA}</span>
<span className="sr-only">{why}</span>
</span>
) : (
<span className="text-xs text-muted-foreground">{why}</span>
)
}
@ -137,9 +151,7 @@ const TerritorialClassCell = ({ municipality }: { municipality: MunicipalityList
<Badge variant={territorialClassBadgeVariant[municipality.territorialClass]}>
{territorialClassLabels[municipality.territorialClass]}
</Badge>
<span className="sr-only">
{formatTerritorialClassWhy(municipality.territorialClassFactors)}
</span>
<span className={isTable ? 'sr-only' : 'text-xs text-muted-foreground'}>{why}</span>
</>
)
}
@ -247,12 +259,10 @@ const municipalityListColumns = ({
concentrationHint,
signalHint,
deficitHint,
classHint,
}: MunicipalityListProps & {
concentrationHint: string
signalHint: string
deficitHint: string
classHint: string
}): Array<CampaignTableColumn<MunicipalityListViewModel>> => [
{
id: 'name',
@ -336,12 +346,14 @@ const municipalityListColumns = ({
state={state}
sortKey="classe"
filterParam="class"
tooltip={classHint}
tooltip={CLASS_COLUMN_HINT}
>
Classe
</MunicipalitySortableHead>
),
cell: (municipality) => <TerritorialClassCell municipality={municipality} />,
cell: (municipality) => (
<TerritorialClassReadout municipality={municipality} layout="table" />
),
// 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) => {
</div>
{isStaffView ? (
<dl className="grid grid-cols-2 gap-2 text-sm">
{/* The card has room for both factors the table trims to one. */}
<div className="col-span-2">
<dt className="text-muted-foreground">Classe</dt>
{municipality.territorialClass === 'sem_base' ? (
<dd className="text-xs text-muted-foreground">{TERRITORIAL_CLASS_NO_DATA}</dd>
) : (
<dd className="flex flex-wrap items-center gap-2">
<Badge
variant={territorialClassBadgeVariant[municipality.territorialClass]}
>
{territorialClassLabels[municipality.territorialClass]}
</Badge>
<span className="text-xs text-muted-foreground">
{formatTerritorialClassWhy(municipality.territorialClassFactors)}
</span>
</dd>
)}
<dd className="flex flex-wrap items-center gap-2">
<TerritorialClassReadout municipality={municipality} layout="card" />
</dd>
</div>
<div>
<dt className="text-muted-foreground">Votos estimados</dt>

View file

@ -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 `<span>` 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 `<span>` 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

View file

@ -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

View file

@ -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<number, StatewideFederalTotals>()
@ -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)

View file

@ -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)}%`

View file

@ -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 = <T extends string>(
raw: string | string[] | undefined,
allowed: ReadonlySet<string>,
): 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()

View file

@ -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<MunicipalityTerritorialClass, string
* the class the allocation queue exists to send people to — takes the amber
* this app already spends on "needs attention" (cold signals, disputed
* support), never the destructive red the priority badge owns on the same row;
* manutenção is routine grey; marginal is a hairline pill. `sem_base` is
* absence of data, not a state: surfaces render it as text, and the list uses
* the em dash the neighboring "2022" column already uses for "nothing here".
* manutenção is routine grey; marginal is a hairline pill.
*
* `sem_base` is absence of data, not a state, so it gets the flattest fill and
* reads differently by surface: the list renders the em dash the neighboring
* "2022" column already uses for "nothing here", while the detail card and the
* dossier keep the pill because there the class sits in a labeled field ("Classe
* …") where a dash would read as a rendering failure rather than as missing data.
*/
export const territorialClassBadgeVariant = {
reduto: 'estimate-confirmed',
@ -86,8 +96,8 @@ const formatTerritorialClassFactor = (factor: TerritorialFactor): string => {
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,

View file

@ -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

View file

@ -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<string>(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<string>(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<PoliticalTrendStatus>(
params.trend,
politicalTrendStatusSet,
)
const classes = parseExhaustiveEnumParam<MunicipalityTerritorialClass>(
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

View file

@ -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<MunicipalityFacetRow[]> } | null,
): Promise<MunicipalityListFilterFacets> => {
// 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<string, Promise<MunicipalityFacetRow[]>>()
if (loadedScope) {
rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows)
rowsByWhere.set(JSON.stringify(loadedScope.where), loadedScope.rows.then(applyClassFilter))
}
const facetRows = (omit: Partial<MunicipalityListState>): Promise<MunicipalityFacetRow[]> => {
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 ?? [])])

View file

@ -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<string>()
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 }

View file

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