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 ( + + + ) + } -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 ( + + + ) } const advisorEntries = ( @@ -268,10 +277,7 @@ export const MunicipalityList = ({ ) : names.length ? ( names.join(', ') ) : ( - - + )}
@@ -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 ? ( - + 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) })