mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
E9 /simplify: consolidate duplicated helpers, dead passthrough, comment drift
Cross-checked by parallel code-quality/performance/reuse reviewers against the E9 commit. Behavior-preserving: - laterTimestamp/resolveMunicipalityLastSignalAt were the same "latest of two ISO timestamps" written twice; both now delegate to a new latestIsoTimestamp in campaignTime.ts, which documents the fixed-width UTC invariant that makes the string compare sound. - Hoisted the frescor comparator's Date.now() out of the per-comparison accessor so the sort key stops drifting mid-sort. - Deleted the unread baseTotal/growthFactor/belowBase passthrough from the statewide goal entry point and fixed a comment citing a symbol (goalDecompositionBelowBase) that never existed; renamed computeStatewideGoalDecomposition -> computeStatewideSuggestedGoals now that nothing is proportionally decomposed. - Collapsed the advisor badge's two copies (desktop table vs mobile card) into one AdvisorStatusBadge; they had quietly diverged on which icon renders for the "no advisor" state. - Renamed the sort-menu's stale "Cobertura" label for advisor coverage to "Assessoria" (E8 already renamed the list column for the same reason); derived the sort-key set from the label record instead of a third parallel list. - Consolidated three zeroed-scenario-record literals into one zeroByVoteEstimateScenario() factory in voteEstimate.ts. - Routed three separate votesByYear[2022] reads through one votesInYear helper in municipalityPotential.ts. - Gated the goal card's empty-state copy on !usesMesaEstimate so it stops telling a município with an existing mesa estimate to enter one. - Swapped a test assertion pinned to a raw Tailwind token for a stable data-signal="cold" hook, and tightened the "desert" test's bound to check against the actual growth factor (the old bound was true for every slug regardless of the formula). Left alone (roadmap decision, not cleanup): sanityCheckSuggestedGoalsByTerritory has no production caller yet (deferred to E11/E12); comment shortened instead of deleting the dead-but-documented code. Verified: tsc --noEmit, pnpm lint (0 warnings), 337 unit + 366 integration tests, pnpm build all green.
This commit is contained in:
parent
51c13153fc
commit
31fcc20ffd
15 changed files with 127 additions and 140 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ export const MunicipalityGoalAccountCard = ({
|
|||
}
|
||||
/>
|
||||
</dl>
|
||||
{suggestedGoal <= 0 ? (
|
||||
{!usesMesaEstimate && suggestedGoal <= 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
data-signal={isCold ? 'cold' : 'fresh'}
|
||||
className={cn('flex flex-col gap-0.5', layout === 'card' && 'text-sm')}
|
||||
title={
|
||||
isCold
|
||||
|
|
@ -149,14 +149,23 @@ const SignalAgeReadout = ({
|
|||
* the overview's coluna da vergonha uses to count them. Non-priority ones
|
||||
* keep the softer pending tone: they are a gap, not a fire.
|
||||
*/
|
||||
const advisorBadgeVariant = (municipality: MunicipalityListViewModel) => {
|
||||
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 (
|
||||
<Badge variant="estimate-confirmed">
|
||||
<CircleCheckIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Coberta
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Badge variant={isPriority ? 'destructive' : 'estimate-pending'}>
|
||||
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{isPriority ? 'Sem responsável' : municipalityListCoverageLabels.sem_assessor}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const advisorEntries = (
|
||||
|
|
@ -268,10 +277,7 @@ export const MunicipalityList = ({
|
|||
) : names.length ? (
|
||||
names.join(', ')
|
||||
) : (
|
||||
<Badge variant={advisorBadgeVariant(municipality)}>
|
||||
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{advisorBadgeLabel(municipality)}
|
||||
</Badge>
|
||||
<AdvisorStatusBadge municipality={municipality} />
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
|
|
@ -343,8 +349,6 @@ export const MunicipalityList = ({
|
|||
</TableHeader>
|
||||
<TableBody>
|
||||
{municipalities.map((municipality) => {
|
||||
const hasAdvisor = municipality.advisorIDs.length > 0
|
||||
|
||||
return (
|
||||
<TableRow key={municipality.id}>
|
||||
<TableCell className="max-w-52 whitespace-normal">
|
||||
|
|
@ -417,14 +421,7 @@ export const MunicipalityList = ({
|
|||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={advisorBadgeVariant(municipality)}>
|
||||
{hasAdvisor ? (
|
||||
<CircleCheckIcon data-icon="inline-start" aria-hidden="true" />
|
||||
) : (
|
||||
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
|
||||
)}
|
||||
{advisorBadgeLabel(municipality)}
|
||||
</Badge>
|
||||
<AdvisorStatusBadge municipality={municipality} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MunicipalityListGoalCoverageCell
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ type CampaignGoalsReader = CampaignUser | User
|
|||
|
||||
/**
|
||||
* Request-scoped read of the `campaignGoals` global (E8 "conta da cadeira" —
|
||||
* state-level vote goal + margin, decomposed into a per-municipality
|
||||
* suggested goal by `municipalityPotential.ts`). Degrau 1 da caching ladder
|
||||
* state-level vote goal + margin, turned into a per-municipality suggested
|
||||
* goal by `municipalityPotential.ts`). Degrau 1 da caching ladder
|
||||
* (React `cache()` per-request dedup): the global changes rarely and every
|
||||
* page that shows coverage needs the same value once per request.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -116,6 +116,21 @@ const bahiaDateTimeDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
|
|||
export const formatBahiaDateTimeLabel = (iso: string): string =>
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -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<number, MunicipalityPledgeAggregate>,
|
||||
): Promise<MunicipalityGoalCoverageBundle> => {
|
||||
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<SuggestedGoalByScenario> = {
|
||||
pessimistic: 0,
|
||||
central: 0,
|
||||
optimistic: 0,
|
||||
}
|
||||
const emptySuggestedGoalByScenario: Readonly<SuggestedGoalByScenario> = 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<MunicipalityGoalAccount> => {
|
||||
const { suggestedGoalBySlug, potentialBySlug } = await loadGoalDecompositionCached(payload, user)
|
||||
const { suggestedGoalBySlug, potentialBySlug } = await loadSuggestedGoalsCached(payload, user)
|
||||
const suggestedGoalByScenario =
|
||||
suggestedGoalBySlug.get(municipality.slug) ?? emptySuggestedGoalByScenario
|
||||
const goalCoverage = computeGoalCoverage(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<VoteEstimateScenario, number>
|
||||
|
|
@ -173,26 +175,19 @@ export type SuggestedGoalByScenario = Record<VoteEstimateScenario, number>
|
|||
* - `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<string>,
|
||||
|
|
@ -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<CampaignGoal, 'stateGoal' | 'margin'>,
|
||||
): {
|
||||
potentialBySlug: Map<string, MunicipalityPotential>
|
||||
suggestedGoalBySlug: Map<string, SuggestedGoalByScenario>
|
||||
/** Σ `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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MunicipalityListSortKey, string>
|
|||
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<string>(municipalityListParamNames)
|
||||
|
||||
const municipalityListSortKeySet = new Set<MunicipalityListSortKey>([
|
||||
'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<string>(Object.keys(municipalityListSortLabels))
|
||||
|
||||
const municipalityListSortDirSet = new Set<MunicipalityListSortDirection>(['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'
|
||||
|
|
|
|||
|
|
@ -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<VoteEstimateScenario, number> => ({
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<VoteEstimateScenario, number> => ({
|
||||
pessimistic: 0,
|
||||
central: 0,
|
||||
optimistic: 0,
|
||||
})
|
||||
|
||||
export type MunicipalityPledgeAggregate = {
|
||||
declaredTotal: number
|
||||
effectiveByScenario: Record<VoteEstimateScenario, number>
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue