teqo-backup/src/utilities/municipalityLabels.ts
Francisco Ignácio Fontoura Solla c7001b75f0 E14: engagement levels N0-N4 and the first allocationDecision writer.
`municipality.engagementLevel` (staff-only, no default) records what the
campaign decided to invest in a município, next to the E8/E10 metrics that
describe what it knows. Moving it is a reallocation decision, so only
unrestricted staff may, every move carries a rationale plus the signals that
would reverse it, and the write files an `allocationDecision` in the same
transaction under an advisory lock taken before the current level is read —
otherwise the snapshot's `from` is a guess. The enum gains `movimento`: a level
move is neither `aceita` nor `descarta`.

No backfill. Null is "Sem nível", and filtering by it is the triage queue.
That is also why the list sorts `nivel` in memory rather than in the query:
Postgres puts NULLs first on DESC, so a native sort would open "N4 primeiro"
on a page of municípios nobody has decided about yet. The filter stays native.

The hysteresis rules from research §6.8 are pure and client-safe, so the
Popover explains a block before submitting and the server re-runs them anyway;
a blocked move answers 409 with typed violations and the override is recorded
with exactly the rules it contradicted.
2026-07-27 21:19:33 -03:00

239 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Municipality display labels shared by list, detail, dossier and map
* 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 { campaignConceptOneLiner } from '@/lib/campaignIntelligenceConcepts'
import {
formatElectionNumber,
formatVoteSharePercent,
oneDecimalFormatter,
} from '@/lib/electionFormat'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import { AT_STANDARD_LQ } from '@/lib/territorialClassAnchors'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels } from '@/lib/voteEstimate'
import type { CampaignUser, Municipality } from '@/payload-types'
import { formatRatioAsPercentLabel } from '@/utilities/goalCoverage'
import { MUNICIPALITY_COLD_SIGNAL_DAYS } from '@/utilities/municipalitySignal'
import type {
MunicipalityTerritorialClass,
TerritorialFactor,
} from '@/utilities/municipalityTerritorialClass'
export const municipalityKindLabels: Record<Municipality['kind'], string> = {
municipio: 'Município',
zona: 'Zona eleitoral',
}
export const municipalityPriorityLabels: Record<NonNullable<Municipality['priority']>, string> = {
alta: 'Prioritária',
normal: 'Normal',
}
export type PoliticalTrendStatus = NonNullable<
NonNullable<Municipality['politicalTrend']>['status']
>
export const politicalTrendLabels: Record<PoliticalTrendStatus, string> = {
favoravel: 'Favorável',
neutra: 'Neutra',
desfavoravel: 'Desfavorável',
}
export const politicalTrendBadgeVariant = {
favoravel: 'estimate-confirmed',
neutra: 'secondary',
desfavoravel: 'destructive',
} as const
export const municipalityListCoverageLabels: Record<'com_assessor' | 'sem_assessor', string> = {
com_assessor: 'Com assessor',
sem_assessor: 'Sem assessor',
}
/** E10 — operational class of the município (vocabulary of the research report). */
export const territorialClassLabels: Record<MunicipalityTerritorialClass, string> = {
reduto: 'Reduto',
expansao: 'Expansão',
manutencao: 'Manutenção',
marginal: 'Marginal',
sem_base: 'Sem base',
}
/**
* Four fills that survive a 25-row scan, plus one that deliberately doesn't.
* Reduto reads as achievement (green, like a confirmed estimate); expansão —
* 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, 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',
expansao: 'estimate-pending',
manutencao: 'secondary',
marginal: 'outline',
sem_base: 'ghost',
} as const
/**
* The same ladder as fills for the map's proportional symbols (B13). Literal
* hex rather than the CSS tokens above because Leaflet writes the SVG `fill`
* ATTRIBUTE, where `var(--estimate-confirmed)` does not resolve — these mirror
* the token values in `styles.css` (see `DESIGN.md` § Status Badge) and must
* be changed together with them.
*
* Saturated over a basemap: the badge tints are tuned for a white row and
* would disappear on top of tiles, so each step is the readable sibling of
* its token, not the token itself.
*/
export const territorialClassMapFill: Record<MunicipalityTerritorialClass, string> = {
reduto: '#16a34a',
expansao: '#d97706',
manutencao: '#78716c',
marginal: '#a8a29e',
sem_base: '#d6d3d1',
}
/**
* The "por quê" behind a class, in the mesa's own phrasing — "aqui a votação
* está 40% abaixo do padrão estadual" is the sentence the research report says
* survives the table; "LQ 0,6" is not. The possessive names the candidate
* ("do candidato"), never "seu", which an assessor reads as their own.
*/
export const formatDominanceAgainstOwnStandard = (lq: number): string => {
if (lq >= AT_STANDARD_LQ.min && lq < AT_STANDARD_LQ.max) {
return 'no padrão estadual do candidato'
}
return lq >= 1
? `${oneDecimalFormatter.format(lq)}× o padrão estadual do candidato`
: `${formatRatioAsPercentLabel(1 - lq)} abaixo do padrão estadual do candidato`
}
const formatTerritorialClassFactor = (factor: TerritorialFactor): string => {
switch (factor.id) {
case 'dominance':
return formatDominanceAgainstOwnStandard(factor.value)
case 'ownShare':
return `${formatVoteSharePercent(factor.value)} da votação estadual do candidato`
case 'field':
return `${formatElectionNumber(Math.round(factor.value))} votos do campo sem captura`
case 'capture':
return `captura de ${formatRatioAsPercentLabel(factor.value)} do campo`
}
}
/** Copy for "no TSE series here" — the class label alone would read as a bug. */
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, 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.length === 0
? TERRITORIAL_CLASS_NO_DATA
: factors.slice(0, 2).map(formatTerritorialClassFactor).join(' · ')
export const formatMunicipalityConcentrationHint = (
totalUnits: number = municipalityCatalog.length,
): string =>
`Percentual da votação estadual do candidato neste município — não o % dos válidos locais. Colocação: posição no catálogo de ${formatElectionNumber(totalUnits)} unidades.`
export const getCampaignScopeLabel = (
role: CampaignUser['role'],
municipalityCount: number,
): string => {
if (role === 'advisor') {
return `${municipalityCount} ${municipalityCount === 1 ? 'município sob sua assessoria' : 'municípios sob sua assessoria'}`
}
if (role === 'leader') {
return `${municipalityCount} ${municipalityCount === 1 ? 'município em que você atua' : 'municípios em que você atua'}`
}
return `${municipalityCount} ${municipalityCount === 1 ? 'município' : 'municípios'}`
}
export const municipalityGeographyParts = (municipality: {
region: string
kind: Municipality['kind']
zoneNumber?: number | null
}): { region: string; zoneSuffix: string | null } => ({
region: municipality.region,
zoneSuffix:
municipality.kind === 'zona' && municipality.zoneNumber != null
? `· ZE ${municipality.zoneNumber}`
: null,
})
/** Short human description of a municipality's geography, e.g. "Chapada Diamantina · ZE 105". */
export const formatMunicipalityGeographyLabel = (municipality: {
region: string
kind: Municipality['kind']
zoneNumber?: number | null
}): string => {
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
return zoneSuffix ? `${region} ${zoneSuffix}` : region
}
/**
* The 12 real column ids in `/campanha/municipios` — 11 staff columns plus
* `lastUpdateAt`, which only the non-staff (leader) view renders. Closed union
* so `municipalityColumnDescriptions` below can't silently drop a column.
*/
export type MunicipalityListColumnId =
| 'name'
| 'region'
| 'kind'
| 'votos'
| 'classe'
| 'nivel'
| 'advisors'
| 'trend'
| 'expectedVotes'
| 'lastSignal'
| 'goalCoverage'
| 'lastUpdateAt'
/**
* E10 "Classe" header hint — kept verbatim rather than swapped for the
* terser E18 `oneLiner` (`classe-territorial`): a 5-way classification needs
* more than one sentence, and the shorter concept text would be a clarity
* downgrade for this specific column.
*/
const CLASS_COLUMN_DESCRIPTION =
'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.'
/**
* B22 — single source of copy for every `/campanha/municipios` column header
* tooltip. E18-documented metrics quote their canonical `oneLiner` instead of
* a second, driftable sentence; the rest is new short pt-BR copy. Computed at
* module scope: every entry here only ever depended on static constants, not
* on props, so there is nothing to recompute per render.
*/
export const municipalityColumnDescriptions: Record<MunicipalityListColumnId, string> = {
name: 'Nome do município e prioridade da campanha.',
region: 'Território de Identidade (Bahia) a que o município pertence.',
kind: 'Município inteiro ou zona eleitoral de Salvador (ZE 1–19).',
votos: formatMunicipalityConcentrationHint(),
classe: CLASS_COLUMN_DESCRIPTION,
nivel: campaignConceptOneLiner('nivel-de-envolvimento'),
advisors: 'Assessor(es) responsável(is) pelo município — o coordenador atribui clicando aqui.',
trend: 'Tendência política percebida pela equipe: favorável, neutra ou desfavorável.',
expectedVotes: campaignConceptOneLiner('meta'),
lastSignal: `Última atualização da equipe ou declaração de liderança, o que for mais recente. Fica destacado a partir de ${MUNICIPALITY_COLD_SIGNAL_DAYS} dias sem registro.`,
// The scenario picker above the table is client state, so the server can
// only sort by one scenario — named here so the ordering never looks
// arbitrary next to whatever scenario is currently selected.
goalCoverage: `${campaignConceptOneLiner('cobertura-da-meta')} Ordena pelo que falta para a meta (meta − comprometido) no cenário ${voteEstimateScenarioLabels[DEFAULT_VOTE_ESTIMATE_SCENARIO]}, independente do cenário selecionado acima.`,
lastUpdateAt: 'Data do último registro da equipe ou de liderança neste município.',
}