teqo/src/utilities/municipalityCandidateComparison.ts
Francisco Ignácio Fontoura Solla cf6ae6c47c Pass 2 W2: correcoes de fronteira lib/utilities e client boundary
- helpers puros movidos utilities->lib: slug, phone, wordStartFilter, voteEstimate (9 inversoes lib->utilities zeradas)
- chaves de consent em src/lib/campaignConsentKeys.ts (client-safe); campaignInvite/campaignConsent re-exportam; onda0ConsentTexts referencia as constantes
- 'server-only' em 21 loaders Payload/cache (CLI segue com --conditions=react-server)
- ciclo de tipos supporterListFilters <-> supporterUi quebrado (SupporterListFilterInput estrutural; sqlFilters idem)
- votePledgeData dividido: votePledgeViews (puro, client-safe) + votePledgeData (loaders, server-only) - padrao contract-module
- predicados de role client-safe em src/lib/campaignRoles.ts; access/shared deriva deles (fonte unica); nav.ts para de importar o barrel campaignAccess (casts 'as CampaignUser' eliminados)
2026-07-25 14:38:41 -03:00

148 lines
5 KiB
TypeScript

import 'server-only'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload, type Payload } from 'payload'
import {
BASELINE_TICKET_2022,
ELECTION_YEAR_2022,
FEDERAL_DEPUTY_OFFICE,
HISTORICAL_SERIES_YEARS,
} from '@/lib/electionResults'
import type { CampaignUser, User } from '@/payload-types'
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
import { ELECTION_TSE_CACHE_TAG } from '@/utilities/electionCache'
import {
municipalityGeographyWhere,
type MunicipalityElectionGeography,
} from '@/utilities/municipalityElectionGeography'
export const MAX_COMPARISON_CANDIDATES = 5
export type CandidateComparisonRow = {
candidateNumber: number
name: string
party: string | null
isReference: boolean
votesByYear: Record<string, number>
}
/**
* Vote comparison between Jorge Solla and selected federal-deputy candidates
* inside one municipality geography, across the TSE series years. This is the
* coordination's core analysis tool ("com quem comparar nesta Praça?").
*/
/** Cross-request cached core — immutable TSE series, busted with `election-tse`. */
const loadMunicipalityCandidateComparisonCached = unstable_cache(
async (
geography: MunicipalityElectionGeography,
candidateNumbers: number[],
): Promise<CandidateComparisonRow[]> => {
const payload = await getPayload({ config: configPromise })
return queryMunicipalityCandidateComparison(payload, geography, candidateNumbers)
},
['municipality-candidate-comparison'],
{ tags: [ELECTION_TSE_CACHE_TAG] },
)
export const loadMunicipalityCandidateComparison = (
user: CampaignUser | User,
geography: MunicipalityElectionGeography,
compareCandidateNumbers: number[],
): Promise<CandidateComparisonRow[]> => {
assertCanReadElectionData(user)
const candidateNumbers = [
BASELINE_TICKET_2022.candidate.candidateNumber,
...compareCandidateNumbers
.filter((number) => number !== BASELINE_TICKET_2022.candidate.candidateNumber)
.slice(0, MAX_COMPARISON_CANDIDATES),
]
return loadMunicipalityCandidateComparisonCached(geography, candidateNumbers)
}
const queryMunicipalityCandidateComparison = async (
payload: Payload,
geography: MunicipalityElectionGeography,
candidateNumbers: number[],
): Promise<CandidateComparisonRow[]> => {
const [votes, registry] = await Promise.all([
payload.find({
collection: 'electionCandidateVote',
where: {
and: [
{ year: { in: [...HISTORICAL_SERIES_YEARS] } },
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
{ turn: { equals: '1' } },
{ voteType: { equals: 'nominal' } },
{ candidateNumber: { in: candidateNumbers } },
municipalityGeographyWhere(geography),
],
},
depth: 0,
limit: 0,
pagination: false,
select: { year: true, candidateNumber: true, candidateName: true, party: true, votes: true },
overrideAccess: true,
}),
payload.find({
collection: 'electionCandidate',
where: {
and: [
{ year: { equals: ELECTION_YEAR_2022 } },
{ office: { equals: FEDERAL_DEPUTY_OFFICE } },
{ turn: { equals: '1' } },
{ candidateNumber: { in: candidateNumbers } },
],
},
depth: 0,
limit: 0,
pagination: false,
select: { candidateNumber: true, urnaName: true, party: true },
overrideAccess: true,
}),
])
const registryByNumber = new Map(
registry.docs.map((candidate) => [candidate.candidateNumber, candidate]),
)
const fallbackNameByNumber = new Map<number, { name: string; party: string | null }>()
const votesByCandidateYear = new Map<number, Map<number, number>>()
for (const row of votes.docs) {
const byYear = votesByCandidateYear.get(row.candidateNumber) ?? new Map<number, number>()
byYear.set(row.year, (byYear.get(row.year) ?? 0) + (row.votes ?? 0))
votesByCandidateYear.set(row.candidateNumber, byYear)
if (!fallbackNameByNumber.has(row.candidateNumber)) {
fallbackNameByNumber.set(row.candidateNumber, {
name: row.candidateName,
party: row.party ?? null,
})
}
}
return candidateNumbers.map((candidateNumber) => {
const registryEntry = registryByNumber.get(candidateNumber)
const fallback = fallbackNameByNumber.get(candidateNumber)
const byYear = votesByCandidateYear.get(candidateNumber) ?? new Map<number, number>()
const isReference = candidateNumber === BASELINE_TICKET_2022.candidate.candidateNumber
const votesByYear: Record<string, number> = {}
for (const year of HISTORICAL_SERIES_YEARS) {
votesByYear[String(year)] = byYear.get(year) ?? 0
}
return {
candidateNumber,
name: isReference
? BASELINE_TICKET_2022.candidate.name
: (registryEntry?.urnaName ?? fallback?.name ?? `Candidato ${candidateNumber}`),
party: isReference
? BASELINE_TICKET_2022.candidate.party
: (registryEntry?.party ?? fallback?.party ?? null),
isReference,
votesByYear,
}
})
}