diff --git a/docs/plans/conta-da-cadeira.md b/docs/plans/conta-da-cadeira.md
index 61adb35a..9369a551 100644
--- a/docs/plans/conta-da-cadeira.md
+++ b/docs/plans/conta-da-cadeira.md
@@ -30,7 +30,7 @@ Todas as 6 fases implementadas conforme o plano de execução, com os ajustes da
- **Derivados puros**: `src/utilities/municipalityPotential.ts` (`projectedValidVotes`, `fieldCeiling`, `projectedFieldCeiling`, `captureRate`, `intraFieldShare`, `rollOff`, `deriveSuggestedGoalsByScenario` — originalmente `decomposeStateGoal`, revista no mesmo dia (ver "Revisão da fórmula da meta sintética" abaixo) —, `sanityCheckSuggestedGoalsByTerritory`) e `src/utilities/goalCoverage.ts` (`meta = expectedVotes[cenário] ?? suggestedGoal`; `comprometido = aggregate.effectiveByScenario[cenário]`, nunca a expectativa da mesa — achado #2). Orquestração server-scoped em `src/utilities/municipalityGoalAccount.ts` (`cache()`-deduplicado entre dashboard/lista/detalhe).
- **UI** (Impeccable classe B, shape → craft → critique → harden): linha "Cobertura da meta" na `CampaignMetricStrip` do dashboard (`CampaignDashboard.tsx`) e do overview da lista (`MunicipalityListOverview.tsx`); nova coluna "Cobertura da meta" em `MunicipalityList.tsx` (a coluna antiga "Cobertura" foi renomeada para "Assessoria" para eliminar a ambiguidade), com célula compartilhada `MunicipalityListGoalCoverageCell.tsx` (variante `compact` para a tabela desktop, `default` para os cards mobile); card novo "Conta da cadeira" (`MunicipalityGoalAccountCard.tsx`) na aba Visão geral do detalhe do município, fixo no cenário `central` (sem seletor de cenário nessa página) — substitui o bloco "Votos estimados" que antes vivia em `MunicipalityStrategyCard.tsx`.
- **Verificação:** `tsc --noEmit`, `pnpm lint`, `pnpm exec knip`, unit + int (incl. `tests/int/campaignGoalsAccess.int.spec.ts` cobrindo a matriz de acesso por papel) e `pnpm build` verdes contra o banco local; `pnpm test:e2e` verde quando executado isolado (flakiness de timeout/`ECONNRESET` observada só sob contenção de recursos do sandbox com múltiplos dev servers em paralelo — não é regressão de código; 1 assertion ajustada para um seletor Playwright não-ambíguo pós-mudança de UI).
-- **Divergências do plano original:** nenhuma decisão de produto mudou; os 6 achados da auditoria (seção acima) e as decisões travadas na sessão foram seguidos à risca. `territoryWarnings` (sanity check por TI) ficam computados e disponíveis em `municipalityPotential.ts`/`computeStatewideGoalDecomposition`, mas **não** ganharam superfície de UI nesta entrega — o brief de shape aprovado (Fase 4a) cobriu só as 4 integrações (dashboard, overview, lista, detalhe); exibir os avisos por TI é candidato natural para E12 (camada TI) ou um fill-in futuro.
+- **Divergências do plano original:** nenhuma decisão de produto mudou; os 6 achados da auditoria (seção acima) e as decisões travadas na sessão foram seguidos à risca. `territoryWarnings` (sanity check por TI) ficam computados e disponíveis em `municipalityPotential.ts`, mas **não** ganharam superfície de UI nesta entrega — o brief de shape aprovado (Fase 4a) cobriu só as 4 integrações (dashboard, overview, lista, detalhe); exibir os avisos por TI é candidato natural para E12 (camada TI) ou um fill-in futuro.
### Revisão da fórmula da meta sintética (2026-07-24, durante o E9)
diff --git a/docs/plans/dossie-municipio.md b/docs/plans/dossie-municipio.md
index ec3ad2be..2a898ae9 100644
--- a/docs/plans/dossie-municipio.md
+++ b/docs/plans/dossie-municipio.md
@@ -87,7 +87,7 @@ Componentes: `src/utilities/municipalityDossierData.ts` (novo — orquestra load
## Explicitamente fora (triage pós-simplify 2026-07-25)
-- Memoização em module-scope de `computeStatewideGoalDecomposition` — pré-existente E8, fora do diff do dossiê (revisitar no plano [conta-da-cadeira.md](conta-da-cadeira.md) se o hot path do dashboard doer).
+- Memoização em module-scope de `computeStatewideSuggestedGoals` — pré-existente E8, fora do diff do dossiê (revisitar no plano [conta-da-cadeira.md](conta-da-cadeira.md) se o hot path do dashboard doer).
- Encolher o page size do feed de sinais de 10 → 5 — o loader compartilhado precisa de `totalDocs`; micro-otimização sem evidência.
- Critique do `MunicipalityGoalAccountCard` (tooltips touch) — sessão E8, já hardenido no follow-up #3 daquele plano; não é dívida do E16.
diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx
index cfae9363..850a2d0a 100644
--- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx
@@ -92,10 +92,10 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
// E9 coluna da vergonha: same scope, filtered down to priority municipalities
// with nobody answering for them. `null` when that is already the filter.
- const isShameFilterActive = state.priority === 'alta' && state.coverage === 'sem_assessor'
- const shameHref = isShameFilterActive
- ? null
- : buildMunicipalityListHref({ ...state, priority: 'alta', coverage: 'sem_assessor' }, 1)
+ const shameHref =
+ state.priority === 'alta' && state.coverage === 'sem_assessor'
+ ? null
+ : buildMunicipalityListHref({ ...state, priority: 'alta', coverage: 'sem_assessor' }, 1)
const listBody = listMunicipalities.length ? (
<>
diff --git a/src/components/campaign/MunicipalityGoalAccountCard.tsx b/src/components/campaign/MunicipalityGoalAccountCard.tsx
index 8d2de3a4..fcf14995 100644
--- a/src/components/campaign/MunicipalityGoalAccountCard.tsx
+++ b/src/components/campaign/MunicipalityGoalAccountCard.tsx
@@ -274,7 +274,7 @@ export const MunicipalityGoalAccountCard = ({
}
/>
- {suggestedGoal <= 0 ? (
+ {!usesMesaEstimate && suggestedGoal <= 0 ? (
Sem votação própria em 2022 aqui, então não há meta sugerida — é praça de expansão:
cadastre a estimativa da mesa manualmente se decidir investir.
diff --git a/src/components/campaign/MunicipalityList.tsx b/src/components/campaign/MunicipalityList.tsx
index ec845e3e..65779439 100644
--- a/src/components/campaign/MunicipalityList.tsx
+++ b/src/components/campaign/MunicipalityList.tsx
@@ -33,6 +33,7 @@ import {
isMunicipalitySignalCold,
MUNICIPALITY_COLD_SIGNAL_DAYS,
municipalityKindLabels,
+ municipalityListCoverageLabels,
municipalityPriorityLabels,
municipalitySignalAgeInDays,
resolveMunicipalityListSort,
@@ -95,9 +96,7 @@ const VotePositionReadout = ({
/**
* E9 frescor: how long since anybody recorded anything here (staff update or
- * leadership pledge). The age leads — "há 24 dias" is what makes a stale row
- * jump out while scanning the queue — with the exact date as the muted
- * second line for whoever needs it.
+ * leadership pledge).
*
* Cold reads in the warning amber, NOT destructive: early in the campaign
* most municípios are past the 21-day threshold, and painting them all red
@@ -120,6 +119,7 @@ const SignalAgeReadout = ({
return (
{
- if (municipality.advisorIDs.length > 0) return 'estimate-confirmed' as const
- return municipality.priority === 'alta' ? ('destructive' as const) : ('estimate-pending' as const)
-}
+const AdvisorStatusBadge = ({ municipality }: { municipality: MunicipalityListViewModel }) => {
+ if (municipality.advisorIDs.length > 0) {
+ return (
+
+
+ Coberta
+
+ )
+ }
-const advisorBadgeLabel = (municipality: MunicipalityListViewModel): string => {
- if (municipality.advisorIDs.length > 0) return 'Coberta'
- return municipality.priority === 'alta' ? 'Sem responsável' : 'Sem assessor'
+ const isPriority = municipality.priority === 'alta'
+ return (
+
+
+ {isPriority ? 'Sem responsável' : municipalityListCoverageLabels.sem_assessor}
+
+ )
}
const advisorEntries = (
@@ -268,10 +277,7 @@ export const MunicipalityList = ({
) : names.length ? (
names.join(', ')
) : (
-
-
- {advisorBadgeLabel(municipality)}
-
+
)}
@@ -343,8 +349,6 @@ export const MunicipalityList = ({
{municipalities.map((municipality) => {
- const hasAdvisor = municipality.advisorIDs.length > 0
-
return (
@@ -417,14 +421,7 @@ export const MunicipalityList = ({
/>
-
- {hasAdvisor ? (
-
- ) : (
-
- )}
- {advisorBadgeLabel(municipality)}
-
+
bahiaDateTimeDisplayFormatter.format(new Date(iso)).replace(', ', ' às ')
+/**
+ * Latest of two ISO timestamps, ignoring nulls. String comparison is only
+ * sound because every writer here produces fixed-width UTC — Payload's own
+ * `createdAt`/`updatedAt` and our `new Date().toISOString()` hooks. An offset
+ * form (`-03:00`) or variable precision would break it.
+ */
+export const latestIsoTimestamp = (
+ left: string | null | undefined,
+ right: string | null | undefined,
+): string | null => {
+ if (!left) return right ?? null
+ if (!right) return left
+ return right > left ? right : left
+}
+
export const getBahiaWeekRange = (now: Date): { start: Date; end: Date } => {
const local = getZonedParts(now)
const localMidnight = { ...local, hour: 0, minute: 0, second: 0 }
diff --git a/src/utilities/municipalityGoalAccount.ts b/src/utilities/municipalityGoalAccount.ts
index ca6eecc1..a1e66c12 100644
--- a/src/utilities/municipalityGoalAccount.ts
+++ b/src/utilities/municipalityGoalAccount.ts
@@ -14,13 +14,14 @@ import {
} from '@/utilities/goalCoverage'
import {
computeMunicipalityPotential,
- computeStatewideGoalDecomposition,
+ computeStatewideSuggestedGoals,
type MunicipalityPotential,
type SuggestedGoalByScenario,
} from '@/utilities/municipalityPotential'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
VOTE_ESTIMATE_SCENARIOS,
+ zeroByVoteEstimateScenario,
type VoteEstimateScenarioFields,
type VoteEstimateScenario,
} from '@/utilities/voteEstimate'
@@ -32,8 +33,8 @@ import {
type GoalAccountReader = CampaignUser | User
/**
- * E8 "conta da cadeira" — request-scoped statewide goal decomposition
- * (`campaignGoals` global + `computeStatewideGoalDecomposition`), degrau 1 da
+ * E8 "conta da cadeira" — request-scoped statewide suggested-goal ladder
+ * (`campaignGoals` global + `computeStatewideSuggestedGoals`), degrau 1 da
* caching ladder. Every surface (dashboard, list overview, list rows, detail
* card) needs the SAME goals — the optimistic scenario's growth factor is
* `stateGoal / Σ base` over the full 435-slug catalog, so this always runs
@@ -41,10 +42,10 @@ type GoalAccountReader = CampaignUser | User
* `cache()`-deduplicated so dashboard + list + detail don't each recompute it
* on requests that touch more than one.
*/
-const loadGoalDecompositionCached = cache(
+const loadSuggestedGoalsCached = cache(
async (payload: Payload, user: GoalAccountReader) => {
const goals = await loadCampaignGoals(payload, user)
- return computeStatewideGoalDecomposition(goals)
+ return computeStatewideSuggestedGoals(goals)
},
)
@@ -71,7 +72,7 @@ export const loadMunicipalityGoalCoverageBundle = async (
}>,
pledgeAggregates: Map,
): Promise => {
- const { suggestedGoalBySlug } = await loadGoalDecompositionCached(payload, user)
+ const { suggestedGoalBySlug } = await loadSuggestedGoalsCached(payload, user)
const coverageByMunicipalityID = new Map<
number,
@@ -104,11 +105,7 @@ export const loadMunicipalityGoalCoverageBundle = async (
}
/** Unknown slug (never in the catalog): zero goal in every scenario, never mutated. */
-const emptySuggestedGoalByScenario: Readonly = {
- pessimistic: 0,
- central: 0,
- optimistic: 0,
-}
+const emptySuggestedGoalByScenario: Readonly = zeroByVoteEstimateScenario()
export type MunicipalityGoalAccount = {
/** Fixed to the `central` scenario, like `goalCoverage` below. */
@@ -129,7 +126,7 @@ export const loadMunicipalityGoalAccount = async (
municipality: { slug: string; expectedVotes?: VoteEstimateScenarioFields | null },
pledgeAggregate: MunicipalityPledgeAggregate,
): Promise => {
- const { suggestedGoalBySlug, potentialBySlug } = await loadGoalDecompositionCached(payload, user)
+ const { suggestedGoalBySlug, potentialBySlug } = await loadSuggestedGoalsCached(payload, user)
const suggestedGoalByScenario =
suggestedGoalBySlug.get(municipality.slug) ?? emptySuggestedGoalByScenario
const goalCoverage = computeGoalCoverage(
diff --git a/src/utilities/municipalityPageData.ts b/src/utilities/municipalityPageData.ts
index 2fd03843..ef33d79e 100644
--- a/src/utilities/municipalityPageData.ts
+++ b/src/utilities/municipalityPageData.ts
@@ -139,7 +139,10 @@ const applyDerivedMunicipalitySort = (
const coverage = goalCoverageByMunicipalityID.get(municipality.id)?.central
return coverage && coverage.goal > 0 ? coverage.deficit : null
})
- case 'frescor':
+ case 'frescor': {
+ // One clock read for the whole sort: the accessor runs per comparison,
+ // and a "now" that drifts mid-sort is not a consistent ordering key.
+ const now = Date.now()
// Descending = coldest first, so "never had a signal" must outrank the
// oldest date instead of sinking as a null: age is +Infinity there.
return sortByNullableValue(docs, dir, (municipality) => {
@@ -148,8 +151,9 @@ const applyDerivedMunicipalitySort = (
pledgeAggregates.get(municipality.id)?.lastPledgeAt ?? null,
)
if (!lastSignalAt) return Number.POSITIVE_INFINITY
- return Date.now() - new Date(lastSignalAt).getTime()
+ return now - new Date(lastSignalAt).getTime()
})
+ }
default:
return docs
}
diff --git a/src/utilities/municipalityPotential.ts b/src/utilities/municipalityPotential.ts
index 221b8768..130048e7 100644
--- a/src/utilities/municipalityPotential.ts
+++ b/src/utilities/municipalityPotential.ts
@@ -21,6 +21,10 @@ import type { VoteEstimateScenario } from '@/utilities/voteEstimate'
* the E8 plan's "rabbit holes vigiados": projection turning into a model).
*/
+/** Own nominal votes in one year — 0 for a year the artifact doesn't cover. */
+const votesInYear = (baseline: MunicipalityFederalBaseline, year: number): number =>
+ baseline.votesByYear[String(year)] ?? 0
+
/**
* Projected 2026 valid votes: weighted average of 2014/2018/2022 federal T1
* valid votes, weighting 2022 double (closer election, more predictive of
@@ -69,8 +73,7 @@ export const projectedFieldCeiling = (baseline: MunicipalityFederalBaseline): nu
export const captureRate = (baseline: MunicipalityFederalBaseline): number | null => {
const ceiling = fieldCeiling(baseline)
if (ceiling <= 0) return null
- const votes2022 = baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0
- return votes2022 / ceiling
+ return votesInYear(baseline, ELECTION_YEAR_2022) / ceiling
}
/**
@@ -83,8 +86,7 @@ export const captureRate = (baseline: MunicipalityFederalBaseline): number | nul
export const intraFieldShare = (baseline: MunicipalityFederalBaseline, year: number): number | null => {
const campo = baseline.campoFederalVotesByYear[String(year)] ?? 0
if (campo <= 0) return null
- const votes = baseline.votesByYear[String(year)] ?? 0
- return votes / campo
+ return votesInYear(baseline, year) / campo
}
export type RollOff = {
@@ -159,7 +161,7 @@ const pessimisticHaircut = (margin: number | null | undefined): number =>
/** Own 2022 nominal votes — the anchor of every suggested goal (E9 revision). */
export const ownVotes2022 = (baseline: MunicipalityFederalBaseline): number =>
- baseline.votesByYear[String(ELECTION_YEAR_2022)] ?? 0
+ votesInYear(baseline, ELECTION_YEAR_2022)
/** Suggested goal for one município across the three scenarios. */
export type SuggestedGoalByScenario = Record
@@ -173,26 +175,19 @@ export type SuggestedGoalByScenario = Record
* - `optimistic` = base × (stateGoal / Σ base) — growth that closes the seat
* account, so Σ optimistic === `stateGoal` exactly
*
- * This replaced (2026-07-24, E9) a decomposition proportional to the
- * projected FIELD ceiling, which read the field's majoritarian vote (Lula's
- * ~5,3 M) instead of the candidate's own (~129 k) and therefore handed
- * strongholds a goal below the votes they already produce while inflating
- * deserts ~17×. Ranking an allocation queue by uncovered deficit needs the
- * opposite: the research report's "voto histórico próprio mais fração do
- * campo não capturado" (`docs/research`, l. 323/331), whose leading indicator
- * is "% do voto histórico da praça coberto por liderança ativa". The
- * "fração do campo não capturado" half is deliberately NOT folded in here —
- * the report conditions it on operational class (defesa carries maintenance,
- * ataque carries growth), which is E10's scope; until then the open field
- * stays a diagnostic (`captureRate`, `projectedFieldCeiling`).
+ * The research report's other half — "fração do campo não capturado" — is
+ * deliberately NOT folded in: the report conditions it on operational class
+ * (defesa carries maintenance, ataque carries growth), which is E10's scope.
+ * Until then the open field stays a diagnostic (`captureRate`,
+ * `projectedFieldCeiling`). Why the E8 field-ceiling decomposition was
+ * replaced: `docs/plans/conta-da-cadeira.md`.
*
* `margin` (the coordination's safety reading) is reused as the pessimistic
* haircut rather than adding a fourth knob to the global.
*
* Guard: a `stateGoal` below Σ base would make the optimistic scenario land
* BELOW central (an "optimistic" that loses votes), so the growth factor is
- * clamped at 1 — `goalDecompositionBelowBase` reports it so the UI can tell
- * the coordination its state goal is under the projected base.
+ * clamped at 1 and `belowBase` says so.
*/
export const deriveSuggestedGoalsByScenario = (
slugs: ReadonlyArray,
@@ -235,7 +230,7 @@ export type TerritorySanityWarning = {
region: BahiaIdentityTerritory
suggestedGoalTotal: number
projectedValidVotesTotal: number
- /** suggestedGoalTotal / projectedValidVotesTotal — > 1 means the decomposed goal exceeds the projected electorate. */
+ /** suggestedGoalTotal / projectedValidVotesTotal — > 1 means the suggested goal exceeds the projected electorate. */
ratio: number
}
@@ -286,38 +281,26 @@ export const sanityCheckSuggestedGoalsByTerritory = (
}
/**
- * Convenience end-to-end: computes every municipality's potential and
- * decomposes the state goal — the single entry point the dashboard/list/
- * detail loaders need (`municipalityGoalAccount.ts`), keyed by slug for O(1)
- * per-municipality lookup instead of a linear scan.
+ * Every municipality's potential plus its suggested goal ladder — the single
+ * entry point the dashboard/list/detail loaders need
+ * (`municipalityGoalAccount.ts`), keyed by slug for O(1) lookup.
*
- * Deliberately does NOT run `sanityCheckSuggestedGoalsByTerritory` here: no
- * caller surfaces `TerritorySanityWarning`s yet (deferred to E12's TI layer
- * — see the E8 plan), so computing them on every dashboard/list/detail
- * request would be pure wasted work. Callers that need the check once E12
- * lands can build the same `{ region, suggestedGoal, projectedValidVotes }`
- * entries from `potentialBySlug` + `municipalityCatalog` and call it
- * directly — it stays exported and unit-tested for that.
+ * Deliberately skips `sanityCheckSuggestedGoalsByTerritory`: nothing surfaces
+ * `TerritorySanityWarning`s yet (E12's TI layer), so running it on every
+ * request would be wasted work.
*/
-export const computeStatewideGoalDecomposition = (
+export const computeStatewideSuggestedGoals = (
goals: Pick,
): {
potentialBySlug: Map
suggestedGoalBySlug: Map
- /** Σ `central` over the whole catalog — "repetir 2022" statewide. */
- baseTotal: number
- /** `stateGoal / baseTotal` (clamped at 1): what the state goal means as growth everywhere. */
- growthFactor: number
- /** True when `stateGoal` sits below the projected base (growth factor clamped). */
- belowBase: boolean
} => {
const potentials = computeAllMunicipalityPotentials()
- const { suggestedGoalBySlug, baseTotal, growthFactor, belowBase } =
- deriveSuggestedGoalsByScenario(
- potentials.map((potential) => potential.slug),
- goals,
- )
+ const { suggestedGoalBySlug } = deriveSuggestedGoalsByScenario(
+ potentials.map((potential) => potential.slug),
+ goals,
+ )
const potentialBySlug = new Map(potentials.map((potential) => [potential.slug, potential]))
- return { potentialBySlug, suggestedGoalBySlug, baseTotal, growthFactor, belowBase }
+ return { potentialBySlug, suggestedGoalBySlug }
}
diff --git a/src/utilities/municipalityUi.ts b/src/utilities/municipalityUi.ts
index d3345785..8981ab24 100644
--- a/src/utilities/municipalityUi.ts
+++ b/src/utilities/municipalityUi.ts
@@ -12,6 +12,7 @@ import {
strictDecimalInteger,
type RawSearchParams as CampaignListRawSearchParams,
} from '@/utilities/campaignListUrl'
+import { latestIsoTimestamp } from '@/utilities/campaignTime'
import { normalizeSearchPhrase } from '@/utilities/wordStartFilter'
export const municipalityPageSize = 25
@@ -71,7 +72,8 @@ export const municipalityListSortLabels: Record
trend: 'Tendência',
expectedVotes: 'Votos estimados',
lastUpdateAt: 'Última atualização',
- coverage: 'Cobertura',
+ /** Advisor coverage — "Cobertura" alone now reads as the goal one (`deficit`). */
+ coverage: 'Assessoria',
/** Short header — definition lives on hover (`formatMunicipalityConcentrationHint`). */
votos: '2022',
deficit: 'Cobertura da meta',
@@ -109,18 +111,9 @@ export const municipalityListParamNames = [
const municipalityListParamNameSet = new Set(municipalityListParamNames)
-const municipalityListSortKeySet = new Set([
- 'name',
- 'region',
- 'kind',
- 'trend',
- 'expectedVotes',
- 'lastUpdateAt',
- 'coverage',
- 'votos',
- 'deficit',
- 'frescor',
-])
+// Derived from the label record (total over the union), so a new sort key is
+// declared in the type and the labels only.
+const municipalityListSortKeySet = new Set(Object.keys(municipalityListSortLabels))
const municipalityListSortDirSet = new Set(['asc', 'desc'])
@@ -159,11 +152,7 @@ export const formatMunicipalityConcentrationHint = (
export const resolveMunicipalityLastSignalAt = (
lastUpdateAt: string | null,
lastPledgeAt: string | null,
-): string | null => {
- if (!lastUpdateAt) return lastPledgeAt
- if (!lastPledgeAt) return lastUpdateAt
- return lastPledgeAt > lastUpdateAt ? lastPledgeAt : lastUpdateAt
-}
+): string | null => latestIsoTimestamp(lastUpdateAt, lastPledgeAt)
/**
* Days since the last signal, floored. The research report only says a
@@ -185,7 +174,12 @@ export const municipalitySignalAgeInDays = (
export const isMunicipalitySignalCold = (ageInDays: number | null): boolean =>
ageInDays === null || ageInDays >= MUNICIPALITY_COLD_SIGNAL_DAYS
-/** "há 3 dias" / "hoje" / "sem sinal" — dense cell copy for the queue. */
+/**
+ * "há 3 dias" / "hoje" / "Sem sinal" — dense cell copy for the queue.
+ * Deliberately not `formatRelativeAge`: its `numeric: 'auto'` yields "ontem"/
+ * "anteontem" and minute/hour granularity, which breaks both the day-based
+ * cold threshold and the tabular-nums scan down the column.
+ */
export const formatMunicipalitySignalAgeLabel = (ageInDays: number | null): string => {
if (ageInDays === null) return 'Sem sinal'
if (ageInDays === 0) return 'hoje'
diff --git a/src/utilities/voteEstimate.ts b/src/utilities/voteEstimate.ts
index 90cb8b77..076d611a 100644
--- a/src/utilities/voteEstimate.ts
+++ b/src/utilities/voteEstimate.ts
@@ -16,6 +16,13 @@ export type VoteEstimateScenarioViewModel = {
optimistic: number | null
}
+/** Fresh zeroed record per scenario — a factory because callers accumulate into it. */
+export const zeroByVoteEstimateScenario = (): Record => ({
+ pessimistic: 0,
+ central: 0,
+ optimistic: 0,
+})
+
/** True when at least one scenario has a recorded estimate. */
export const hasAnyEstimate = (estimates: VoteEstimateScenarioViewModel): boolean =>
estimates.pessimistic != null || estimates.central != null || estimates.optimistic != null
diff --git a/src/utilities/votePledgeData.ts b/src/utilities/votePledgeData.ts
index 625fc2ee..383d48bf 100644
--- a/src/utilities/votePledgeData.ts
+++ b/src/utilities/votePledgeData.ts
@@ -1,6 +1,7 @@
import type { Payload, PayloadRequest } from 'payload'
import type { CampaignUser, VotePledge } from '@/payload-types'
+import { latestIsoTimestamp } from '@/utilities/campaignTime'
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
@@ -8,6 +9,7 @@ import {
resolveMunicipalityStaffVoteTotalForScenario,
toVoteEstimateScenarioViewModel,
VOTE_ESTIMATE_SCENARIOS,
+ zeroByVoteEstimateScenario,
type VoteEstimateScenario,
type VoteEstimateScenarioFields,
type VoteEstimateScenarioViewModel,
@@ -21,12 +23,6 @@ import {
export type { VoteEstimateScenario, VoteEstimateScenarioViewModel }
-const emptyEffectiveByScenario = (): Record => ({
- pessimistic: 0,
- central: 0,
- optimistic: 0,
-})
-
export type MunicipalityPledgeAggregate = {
declaredTotal: number
effectiveByScenario: Record
@@ -43,7 +39,7 @@ export type MunicipalityPledgeAggregate = {
export const createEmptyMunicipalityPledgeAggregate = (): MunicipalityPledgeAggregate => ({
declaredTotal: 0,
- effectiveByScenario: emptyEffectiveByScenario(),
+ effectiveByScenario: zeroByVoteEstimateScenario(),
pledgeCount: 0,
missingEstimateCount: 0,
lastPledgeAt: null,
@@ -103,9 +99,9 @@ export const aggregateMunicipalityPledgesFromRows = (
}
aggregate.pledgeCount += 1
if (!pledgeHasAnyEstimate(row.estimatedVotes)) aggregate.missingEstimateCount += 1
- aggregate.lastPledgeAt = laterTimestamp(
+ aggregate.lastPledgeAt = latestIsoTimestamp(
aggregate.lastPledgeAt,
- laterTimestamp(row.declaredAt, row.estimatedAt),
+ latestIsoTimestamp(row.declaredAt, row.estimatedAt),
)
}
@@ -126,7 +122,7 @@ export const rollupMunicipalityStaffVotes = (
scenario: VoteEstimateScenario = DEFAULT_VOTE_ESTIMATE_SCENARIO,
): MunicipalityStaffVoteRollup => {
let staffVoteTotal = 0
- const staffVoteTotalByScenario = emptyEffectiveByScenario()
+ const staffVoteTotalByScenario = zeroByVoteEstimateScenario()
let declaredVotesTotal = 0
let pledgeCount = 0
let missingEstimateCount = 0
@@ -160,16 +156,6 @@ export const rollupMunicipalityStaffVotes = (
const pledgeHasAnyEstimate = (estimated: VoteEstimateScenarioFields | null | undefined): boolean =>
estimated?.pessimistic != null || estimated?.central != null || estimated?.optimistic != null
-/** Latest of two ISO timestamps, ignoring nulls (string compare is safe for ISO-8601 UTC). */
-const laterTimestamp = (
- left: string | null | undefined,
- right: string | null | undefined,
-): string | null => {
- if (!left) return right ?? null
- if (!right) return left
- return right > left ? right : left
-}
-
/**
* Staff-only aggregate over an already access-checked municipality id set.
* Intentional admin bypass: callers pass municipality ids the actor may read.
@@ -205,10 +191,12 @@ export const aggregatePledgesByMunicipality = async (
})
for (const doc of result.docs) {
- const municipalityID = relationshipId((doc as VotePledge).municipality)
+ // `select` narrows the runtime shape, not the static type Payload returns.
+ const pledge = doc as VotePledge
+ const municipalityID = relationshipId(pledge.municipality)
if (municipalityID === null) continue
- const declared = (doc as VotePledge).declaredVotes ?? 0
- const estimated = (doc as VotePledge).estimatedVotes
+ const declared = pledge.declaredVotes ?? 0
+ const estimated = pledge.estimatedVotes
const current = aggregates.get(municipalityID) ?? createEmptyMunicipalityPledgeAggregate()
current.declaredTotal += declared
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
@@ -220,9 +208,9 @@ export const aggregatePledgesByMunicipality = async (
}
current.pledgeCount += 1
if (!pledgeHasAnyEstimate(estimated)) current.missingEstimateCount += 1
- current.lastPledgeAt = laterTimestamp(
+ current.lastPledgeAt = latestIsoTimestamp(
current.lastPledgeAt,
- laterTimestamp((doc as VotePledge).declaredAt, (doc as VotePledge).estimatedAt),
+ latestIsoTimestamp(pledge.declaredAt, pledge.estimatedAt),
)
aggregates.set(municipalityID, current)
}
diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts
index 4c35c2d2..ec0fa5b4 100644
--- a/tests/unit/campaignComponents.unit.spec.ts
+++ b/tests/unit/campaignComponents.unit.spec.ts
@@ -270,8 +270,7 @@ describe('campaign visual foundation', () => {
// empty state); the status badge is what escalates to "Sem responsável".
expect(html).toContain('Sem responsável')
expect(html).toContain(`há ${staleDays} dias`)
- // Cold is the warning tone, never destructive — that stays for the badge above.
- expect(html).toContain('text-estimate-pending-foreground')
+ expect(html).toContain('data-signal="cold"')
expect(html).toContain('Último sinal')
})
diff --git a/tests/unit/municipalityPotential.unit.spec.ts b/tests/unit/municipalityPotential.unit.spec.ts
index ff067e00..0a3e9d66 100644
--- a/tests/unit/municipalityPotential.unit.spec.ts
+++ b/tests/unit/municipalityPotential.unit.spec.ts
@@ -184,17 +184,20 @@ describe('deriveSuggestedGoalsByScenario', () => {
expect(goal?.optimistic).toBeGreaterThan(own2022)
})
- it('leaves a desert with a near-zero goal instead of inflating it', () => {
+ it('leaves a desert near its own vote instead of inflating it (E8 asked 813 for 47 votes)', () => {
const slug = 'campo-formoso'
const own2022 = ownVotes2022(getMunicipalityFederalBaseline(slug))
+ expect(own2022).toBeLessThan(100) // guards the fixture, not the formula
- const { suggestedGoalBySlug } = deriveSuggestedGoalsByScenario(catalogSlugs(), {
+ const { suggestedGoalBySlug, growthFactor } = deriveSuggestedGoalsByScenario(catalogSlugs(), {
stateGoal: 150_000,
margin: 10,
})
const goal = suggestedGoalBySlug.get(slug)
- expect(goal?.central).toBe(own2022)
+ // Growth is the same statewide factor everywhere — a desert cannot be
+ // handed a goal an order of magnitude above the votes it produced.
+ expect(goal?.optimistic).toBeCloseTo(own2022 * growthFactor, 6)
expect(goal?.optimistic).toBeLessThan(own2022 * 2)
})