teqo-backup/src/lib/ticketPartnerOpportunities.ts
Francisco Solla 8dd60cf962 A6 — Dobradinha 2026 automática quando o TSE publicar candidaturas (pós-15/08) (#82)
* docs(A6): freshness audit da #27 no plano de dobradinhas 2026

electionInsights.ts foi deletado no Pass 2 W4a (modulo puro novo),
electionAlliances.ts nao nasce (campoParties + electionPartySpectrum ja
carregam a taxonomia), questoes em aberto resolvidas (tiers, score,
proporcionais, lockdown de lideranca), card na aba Eleicoes, overview da
lista fora de escopo, cache election-tse com bust na conciliacao Fase 5.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

* feat(A6): insight de dobradinhas 2026 na aba Eleicoes do municipio

Lista ranqueada de candidatos proporcionais que concorrem de novo em 2026
(runningAgain2026=sim) por geografia do municipio: score 0,6 alinhamento
(campo 2026 + espectro Bolognesi) + 0,4 forca local normalizada pelo maximo
do conjunto. Estado pendente ate a conciliacao pos-15/08; Solla excluido
(referencia, nao parceiro); leitura derivada sem escrita; loader cacheado
sob a tag election-tse como os irmaos; link para /campanha/dobradinhas
como caminho de registro operacional.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

* test(A6): cobertura unit/int do insight + lease das colecoes eleitorais

Unit: tiers (campo/espectro/falha-para-o-meio), score, ordenacao, empates,
limite, zero votos. Int: pending2026 sem conciliacao (mesmo com votos
locais), so adversarios, misto com exclusao de Solla/nao e geografia
alheia. Lease advisory election-collections serializa os dois specs que
tratam as colecoes eleitorais como test-owned (o import wipe-all apagaria
as fixtures do novo spec no meio do assert, 48-way paralelo).

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

* refactor(A6): fixes do /simplify (3 reviewers paralelos)

P1: campo coalition morto removido do input/select/mapping (sem consumidor).
P1: hooks do lease com timeout explicito de 120s nos dois specs eleitorais
(o wait advisory passa dos 10s default do Vitest sob contencao — flake em
ambas as direcoes). P2: sonda de conciliacao via payload.count (idioma da
casa), desempate final por candidateNumber (ordem deterministica), geography
como prop na secao (sem lookup duplicado), casing Dep. Federal/Estadual
alinhado, marcador 1313 via BASELINE_TICKET_2022, entrada no CHANGELOG.

Declinados com justificativa: zones como tupla nao-vazia (churn em tipo
compartilhado por assert provada no catalogo) e early-start da promise
antes do Promise.all (loader-dentro-do-Suspense e o idioma da casa; ganho
so em cache frio).

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
2026-07-31 00:14:27 -03:00

129 lines
4.5 KiB
TypeScript

import { isCampoParty } from '@/lib/campoParties'
import { partySpectrum } from '@/lib/electionPartySpectrum'
import type { ElectionOffice } from '@/lib/electionResults'
/**
* A6 — dobradinha opportunities: proportional candidates running again in 2026
* (`runningAgain2026 === 'sim'`), ranked for one municipality geography by
* political alignment with the PT/Solla ticket and local 2022 electoral force.
*
* The alignment taxonomy composes the two existing party maps instead of
* inventing a third one: the curated 2026 field (`campoParties.ts`, FE Brasil)
* and the Bolognesi et al. 2022 ideological spectrum (`electionPartySpectrum.ts`
* — the continuous means were kept there explicitly "shared with future A6
* tiers"). Unknown parties fail to the MIDDLE (`neutro`), never to `aliado`
* (would invent trust) nor to `adversario` (would invent hostility).
*/
export const TICKET_PARTNER_OFFICES = ['deputado_federal', 'deputado_estadual'] as const
export type TicketPartnerOffice = (typeof TICKET_PARTNER_OFFICES)[number]
export const isTicketPartnerOffice = (office: ElectionOffice): office is TicketPartnerOffice =>
(TICKET_PARTNER_OFFICES as readonly ElectionOffice[]).includes(office)
export type TicketPartnerTier = 'aliado' | 'aliadoHistorico' | 'neutro' | 'adversario'
export const ticketPartnerTierLabels: Record<TicketPartnerTier, string> = {
aliado: 'Aliado',
aliadoHistorico: 'Aliado histórico',
neutro: 'Neutro',
adversario: 'Adversário',
}
export const ticketPartnerOfficeLabels: Record<TicketPartnerOffice, string> = {
deputado_federal: 'Dep. Federal',
deputado_estadual: 'Dep. Estadual',
}
/** Campo year read for the tier lookup — FE Brasil carries over per campoParties. */
const TICKET_PARTNER_CAMPO_YEAR = 2026
export const ticketPartnerTierForParty = (party: string | null | undefined): TicketPartnerTier => {
if (isCampoParty(party, TICKET_PARTNER_CAMPO_YEAR)) return 'aliado'
const spectrum = partySpectrum(party)
switch (spectrum) {
case 'esquerda':
return 'aliadoHistorico'
case 'centro':
return 'neutro'
case 'direita':
return 'adversario'
case null:
return 'neutro'
default: {
const exhaustive: never = spectrum
throw new Error(`Unhandled party spectrum: ${String(exhaustive)}`)
}
}
}
/**
* Score weights (default documented in docs/plans/insight-dobradinha-2026.md,
* revisable with product): alignment outweighs force — a dobradinha is first a
* political-trust decision, then a vote count.
*/
const TICKET_PARTNER_ALIGNMENT_WEIGHT = 0.6
const TICKET_PARTNER_VOTES_WEIGHT = 0.4
const TICKET_PARTNER_TIER_WEIGHTS: Record<TicketPartnerTier, number> = {
aliado: 1,
aliadoHistorico: 0.7,
neutro: 0.35,
adversario: 0.1,
}
export const TICKET_PARTNER_LIST_LIMIT = 10
export type TicketPartnerInput = {
office: TicketPartnerOffice
candidateNumber: number
name: string
party: string | null
elected2022: boolean
votes2022: number
}
export type TicketPartnerOpportunity = TicketPartnerInput & {
tier: TicketPartnerTier
score: number
}
export type TicketPartnerResult =
| { status: 'pending2026' }
| { status: 'ready'; opportunities: TicketPartnerOpportunity[] }
/**
* Rank candidates by `0.6 * tierWeight + 0.4 * normalizedVotes`, where votes
* are normalized by the strongest candidate INSIDE the municipality set — a
* relative, local reading, never a statewide share (docs/research kernel).
* Ties break on raw votes, then name.
*/
export const computeTicketPartnerOpportunities = (
candidates: readonly TicketPartnerInput[],
limit = TICKET_PARTNER_LIST_LIMIT,
): TicketPartnerOpportunity[] => {
const maxVotes = Math.max(0, ...candidates.map((candidate) => Math.max(0, candidate.votes2022)))
const scored = candidates.map((candidate) => {
const tier = ticketPartnerTierForParty(candidate.party)
const normalizedVotes = maxVotes > 0 ? Math.max(0, candidate.votes2022) / maxVotes : 0
const score =
Math.round(
(TICKET_PARTNER_ALIGNMENT_WEIGHT * TICKET_PARTNER_TIER_WEIGHTS[tier] +
TICKET_PARTNER_VOTES_WEIGHT * normalizedVotes) *
1000,
) / 1000
return { ...candidate, tier, score }
})
scored.sort(
(a, b) =>
b.score - a.score ||
b.votes2022 - a.votes2022 ||
a.name.localeCompare(b.name, 'pt-BR') ||
// Same-name urna candidates are a real TSE phenomenon — keep the order
// deterministic across requests and cache entries.
a.candidateNumber - b.candidateNumber,
)
return scored.slice(0, limit)
}