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:
parent
63fe4431bf
commit
0ef4ddac1d
15 changed files with 126 additions and 136 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}%`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ?? [])])
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue