Cache the campaign hot path: committed TSE artifact + request-scoped dedup

- committed artifact src/lib/electionAggregates/bahia-federal-baseline.json
  (Solla nominal + federal T1 valid votes per catalog municipality × TSE year,
  Salvador split by zone) generated by pnpm build:election-aggregates from the
  locally seeded TSE data; typed loader src/lib/bahiaElectionAggregates.ts;
  snapshot spec pins artifact ↔ catalog ↔ baseline ticket
- map bundle: historical years now read the artifact (was 6 statewide
  city×zone scans per dashboard visit); compare mode keeps only the OTHER
  candidate on the DB path (Solla side from the artifact)
- loadMunicipalityScope (React cache()): one municipality find + pledge
  aggregate per request shared by dashboard KPIs, map bundle, and the
  municípios list overview (was 2-3 duplicate loads)
- unstable_cache + election-tse tag on the immutable elections-tab loaders
  (baseline, candidate comparison, candidate options); tag added to the
  /api/revalidate allowlist — runbook: re-seed then bust the tag
- municipalityMapContract.ts: client-safe map vocabulary/bundle types so the
  client panel no longer imports the server data module (build had pulled
  @payload-config into the client graph); municipalityMapData is server-only
  and re-exports the contract; PLAZA_MAP_* renamed to MUNICIPALITY_MAP_*
- petition generateStaticParams enumerates bare ids (getPetitionIds)
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-23 23:06:28 -03:00
parent 0e4e6855dc
commit a32ba3b4f0
22 changed files with 5757 additions and 185 deletions

View file

@ -0,0 +1,111 @@
/**
* Builds the committed per-municipality election baseline artifact consumed by
* the campaign map (src/lib/electionAggregates/bahia-federal-baseline.json).
*
* Why an artifact instead of runtime queries: the 2014/2018/2022 TSE results
* are immutable, but the map used to re-scan statewide city×zone slices on
* every dashboard visit. Precomputing the per-municipality sums (Solla nominal
* votes + federal T1 valid votes, cut by the same `municipalityCatalog`
* geography used at runtime — Salvador split into zone municipalities, every
* other municipality whole) removes all historical-year DB work.
*
* Why not compute during `pnpm build`: Vercel builds must not depend on the
* production database CONTENT (TSE seeds are local-only by policy), and the
* artifact should only change when the electoral scope deliberately changes.
*
* Prerequisite: `pnpm db:seed:tse` (2022) + `--year=2018` + `--year=2014` on
* the LOCAL database.
*
* Usage:
* pnpm build:election-aggregates
*
* Safety: read-only against the local database (assertLocalDatabase guard);
* writes only src/lib/electionAggregates/.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { config as loadEnv } from 'dotenv'
import { getPayload } from 'payload'
import { assertLocalDatabase } from './assert-local-database.mjs'
loadEnv({ path: '.env.local' })
loadEnv({ path: '.env' })
const payloadConfig = (await import('../src/payload.config.ts')).default
const { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } = await import(
'../src/lib/electionResults.ts'
)
const { municipalityCatalog } = await import('../src/lib/municipalityCatalog.ts')
const { municipalityElectionGeography } = await import(
'../src/utilities/municipalityElectionGeography.ts'
)
const {
loadCandidateVotesByCityZone,
loadValidVotesByCityZone,
sumVotesForGeography,
} = await import('../src/utilities/municipalityElectoralBaseline.ts')
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const OUTPUT_DIR = join(ROOT, 'src', 'lib', 'electionAggregates')
const OUTPUT_FILE = join(OUTPUT_DIR, 'bahia-federal-baseline.json')
assertLocalDatabase(
'build:election-aggregates',
'This script only reads the LOCAL seeded TSE data (pnpm db:seed:tse).',
)
const payload = await getPayload({ config: payloadConfig })
/** CLI reader — satisfies the election-data read assertion (admin realm). */
const cliReader = { collection: 'users' }
const candidateNumber = BASELINE_TICKET_2022.candidate.candidateNumber
const municipalities = {}
for (const entry of municipalityCatalog) {
municipalities[entry.slug] = { votesByYear: {}, validVotesByYear: {} }
}
for (const year of HISTORICAL_SERIES_YEARS) {
const [candidateVotes, validVotes] = await Promise.all([
loadCandidateVotesByCityZone(payload, cliReader, { year, candidateNumber }),
loadValidVotesByCityZone(payload, cliReader, { year }),
])
if (candidateVotes.size === 0) {
console.error(
`[build:election-aggregates] No nominal votes found for ${year}. ` +
`Seed the local database first: pnpm db:seed:tse -- --year=${year}`,
)
process.exit(1)
}
let yearTotal = 0
for (const entry of municipalityCatalog) {
const geography = municipalityElectionGeography(entry)
const votes = sumVotesForGeography(candidateVotes, geography)
const valid = sumVotesForGeography(validVotes, geography)
municipalities[entry.slug].votesByYear[String(year)] = votes
municipalities[entry.slug].validVotesByYear[String(year)] = valid
yearTotal += votes
}
console.log(`[build:election-aggregates] ${year}: ${yearTotal} votes for #${candidateNumber}.`)
}
const artifact = {
provenance:
'Derived from TSE open data seeded by pnpm db:seed:tse; regenerate with pnpm build:election-aggregates.',
candidateNumber,
candidateName: BASELINE_TICKET_2022.candidate.name,
years: [...HISTORICAL_SERIES_YEARS],
municipalities,
}
await mkdir(OUTPUT_DIR, { recursive: true })
await writeFile(OUTPUT_FILE, `${JSON.stringify(artifact, null, 2)}\n`)
console.log(
`[build:election-aggregates] Wrote ${OUTPUT_FILE} (${municipalityCatalog.length} municipalities × ${HISTORICAL_SERIES_YEARS.length} years).`,
)
process.exit(0)

View file

@ -196,7 +196,7 @@ const OverviewTab = async ({
const ElectionsTab = async ({
slug,
rawSearchParams,
payloadUser: { payload, user },
payloadUser: { user },
}: {
slug: string
rawSearchParams: MunicipalityDetailSearchParams
@ -213,11 +213,11 @@ const ElectionsTab = async ({
].slice(0, MAX_COMPARISON_CANDIDATES)
const [baseline, comparisonRows, candidateOptions] = await Promise.all([
geography ? loadMunicipalityElectoralBaseline(payload, user, geography) : null,
geography ? loadMunicipalityElectoralBaseline(user, geography) : null,
geography
? loadMunicipalityCandidateComparison(payload, user, geography, compareNumbers)
? loadMunicipalityCandidateComparison(user, geography, compareNumbers)
: ([] as Awaited<ReturnType<typeof loadMunicipalityCandidateComparison>>),
loadFederalCandidateOptions(payload, user),
loadFederalCandidateOptions(user),
])
return (

View file

@ -42,7 +42,7 @@ export default async function CampaignHomePage({ searchParams }: CampaignHomePag
isStaff
? loadMunicipalityMapBundle(payload, user, { compare: rawSearchParams.compare })
: Promise.resolve(null),
isStaff ? loadFederalCandidateOptions(payload, user) : Promise.resolve([]),
isStaff ? loadFederalCandidateOptions(user) : Promise.resolve([]),
])
return (

View file

@ -3,7 +3,7 @@ import { MetaPixel } from '@/components/MetaPixel'
import { SignatureCounter } from '@/components/SignatureCounter'
import { getSignatureCount } from '@/app/(frontend)/actions/getSignatureCount'
import { normalizeFacebookPixelId } from '@/lib/facebookPixel'
import { getCachedDocumentById, getDocuments } from '@/utilities/documents'
import { getCachedDocumentById, getPetitionIds } from '@/utilities/documents'
import { getCachedGlobal } from '@/utilities/globals'
import { extractFirstImageFromLexical } from '@/utilities/extractFirstImageFromLexical'
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
@ -26,11 +26,10 @@ const toAbsoluteUrl = (url: string, siteUrl: string) =>
: `${stripTrailingSlash(siteUrl)}${url.startsWith('/') ? '' : '/'}${url}`
export async function generateStaticParams() {
const payload = await getDocuments('petition')
// Build-time enumeration only needs the ids — skip relationship population.
const ids = await getPetitionIds()
return payload.docs.map((doc) => ({
id: doc.id,
}))
return ids.map((id) => ({ id }))
}
export async function generateMetadata({

View file

@ -14,7 +14,7 @@ import { Button } from '@/components/ui/button'
import { formatElectionNumber } from '@/lib/electionInsights'
import type { StaffDashboardView } from '@/utilities/campaignDashboardData'
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
import type { MunicipalityMapBundle } from '@/utilities/municipalityMapData'
import type { MunicipalityMapBundle } from '@/utilities/municipalityMapContract'
import {
formatVoteEstimateEndpointsLabel,
voteEstimateScenarioLabels,

View file

@ -4,7 +4,7 @@ import type { BahiaMapFeatureInfo } from '@/components/campaign/BahiaMap'
import { Button } from '@/components/ui/button'
import { formatElectionNumber } from '@/lib/electionInsights'
import type { MunicipalityMapNavigation } from '@/utilities/municipalityMapNavigation'
import type { MunicipalityMapScaleMode } from '@/utilities/municipalityMapData'
import type { MunicipalityMapScaleMode } from '@/utilities/municipalityMapContract'
type MapFeatureReadoutProps = {
feature: BahiaMapFeatureInfo | null

View file

@ -19,14 +19,14 @@ import { computeValidVoteShares, divergingGradientCss } from '@/lib/choroplethCo
import { formatElectionNumber } from '@/lib/electionInsights'
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
import {
PLAZA_MAP_SCALE_MODES,
PLAZA_MAP_YEARS,
MUNICIPALITY_MAP_SCALE_MODES,
MUNICIPALITY_MAP_YEARS,
municipalityMapScaleModeLabels,
municipalityMapYearLabels,
type MunicipalityMapBundle,
type MunicipalityMapScaleMode,
type MunicipalityMapYear,
} from '@/utilities/municipalityMapData'
} from '@/utilities/municipalityMapContract'
import { resolveMunicipalityMapNavigation } from '@/utilities/municipalityMapNavigation'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
@ -73,7 +73,10 @@ export const MunicipalityMapPanel = ({
return bundle.valuesByYear[String(year)] ?? {}
}, [bundle, comparison, comparisonActive, estimateScenario, year])
const validVotesForYear = bundle.validVotesByYear[String(year)] ?? {}
const validVotesForYear = useMemo(
() => bundle.validVotesByYear[String(year)] ?? {},
[bundle.validVotesByYear, year],
)
const displayValues = useMemo(() => {
if (percentScaleActive) {
@ -179,7 +182,7 @@ export const MunicipalityMapPanel = ({
onChange={(event) => setYear(Number(event.target.value) as MunicipalityMapYear)}
className="min-h-11 w-full"
>
{PLAZA_MAP_YEARS.map((entry) => (
{MUNICIPALITY_MAP_YEARS.map((entry) => (
<NativeSelectOption key={entry} value={String(entry)}>
{municipalityMapYearLabels[entry]}
</NativeSelectOption>
@ -195,7 +198,7 @@ export const MunicipalityMapPanel = ({
onChange={(event) => setScaleMode(event.target.value as MunicipalityMapScaleMode)}
className="min-h-11 w-full"
>
{PLAZA_MAP_SCALE_MODES.map((mode) => (
{MUNICIPALITY_MAP_SCALE_MODES.map((mode) => (
<NativeSelectOption key={mode} value={mode}>
{municipalityMapScaleModeLabels[mode]}
</NativeSelectOption>

View file

@ -3,7 +3,7 @@
import dynamic from 'next/dynamic'
import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
import type { MunicipalityMapBundle } from '@/utilities/municipalityMapData'
import type { MunicipalityMapBundle } from '@/utilities/municipalityMapContract'
const MunicipalityMapPanelLazy = dynamic(
() => import('@/components/campaign/MunicipalityMapPanel').then((module) => module.MunicipalityMapPanel),

View file

@ -0,0 +1,44 @@
import artifactJson from '@/lib/electionAggregates/bahia-federal-baseline.json'
/**
* Committed per-municipality election baseline (immutable TSE 2014/2018/2022):
* Solla nominal votes + federal T1 valid votes, pre-cut by the same
* `municipalityCatalog` geography the runtime uses (Salvador split by zone,
* every other municipality whole — Camaçari included).
*
* Generated by `pnpm build:election-aggregates` from the locally seeded TSE
* data — never computed at build or request time (see the script header for
* the rationale). A snapshot test pins artifact keys ↔ catalog slugs.
*/
export type MunicipalityFederalBaseline = {
/** TSE year (as string) → Solla nominal votes inside the municipality geography. */
votesByYear: Record<string, number>
/** TSE year (as string) → federal T1 valid votes inside the municipality geography. */
validVotesByYear: Record<string, number>
}
type FederalBaselineArtifact = {
provenance: string
candidateNumber: number
candidateName: string
years: number[]
municipalities: Record<string, MunicipalityFederalBaseline>
}
const artifact = artifactJson as FederalBaselineArtifact
export const FEDERAL_BASELINE_CANDIDATE_NUMBER = artifact.candidateNumber
export const federalBaselineMunicipalitySlugs = (): string[] =>
Object.keys(artifact.municipalities)
const EMPTY_BASELINE: MunicipalityFederalBaseline = { votesByYear: {}, validVotesByYear: {} }
/**
* Baseline for one catalog municipality slug. Unknown slugs return an empty
* baseline (renders as 0 votes) — the artifact↔catalog snapshot test is the
* guard against silent drift.
*/
export const getMunicipalityFederalBaseline = (slug: string): MunicipalityFederalBaseline =>
artifact.municipalities[slug] ?? EMPTY_BASELINE

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,10 @@
import type { Payload } from 'payload'
import type { CampaignUser, VotePledge } from '@/payload-types'
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
import type { VoteEstimateScenario } from '@/utilities/voteEstimate'
import {
aggregatePledgesByMunicipality,
rollupMunicipalityStaffVotes,
} from '@/utilities/votePledgeData'
import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeData'
export type StaffDashboardView = {
kind: 'staff'
@ -62,24 +60,17 @@ export const getCampaignDashboardData = async (
payload: Payload,
user: CampaignUser,
): Promise<StaffDashboardView> => {
const municipalities = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
select: { name: true, slug: true, advisors: true, priority: true, expectedVotes: true },
where: {},
user,
overrideAccess: false,
})
// Request-scoped shared load — the map bundle on the same page reuses it.
const scope = await loadMunicipalityScope(payload, user, {})
const municipalities = { docs: scope.municipalities }
const pledgeAggregates = scope.pledgeAggregates
const municipalityIDs = municipalities.docs.map((municipality) => municipality.id)
const municipalityById = new Map(
municipalities.docs.map((municipality) => [municipality.id, municipality]),
)
// Independent of each other — only depend on municipalityIDs.
const [pledgeAggregates, missingEstimatePledges, recentUpdatesResult] = await Promise.all([
aggregatePledgesByMunicipality(payload, municipalityIDs),
const [missingEstimatePledges, recentUpdatesResult] = await Promise.all([
municipalityIDs.length
? payload.find({
collection: 'votePledge',

View file

@ -0,0 +1,74 @@
import 'server-only'
import { cache } from 'react'
import type { Payload, Where } from 'payload'
import type { CampaignUser, Municipality } from '@/payload-types'
import {
aggregatePledgesByMunicipality,
type MunicipalityPledgeAggregate,
} from '@/utilities/votePledgeData'
/** Superset of the fields the dashboard, map, and list overview consume. */
export type ScopedMunicipalityDoc = Pick<
Municipality,
'id' | 'name' | 'slug' | 'kind' | 'ibgeCode' | 'advisors' | 'priority' | 'expectedVotes'
>
export type MunicipalityScope = {
municipalities: ScopedMunicipalityDoc[]
pledgeAggregates: Map<number, MunicipalityPledgeAggregate>
}
const loadScope = async (
payload: Payload,
user: CampaignUser,
where: Where,
): Promise<MunicipalityScope> => {
const result = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
select: {
name: true,
slug: true,
kind: true,
ibgeCode: true,
advisors: true,
priority: true,
expectedVotes: true,
},
where,
user,
overrideAccess: false,
})
const municipalities = result.docs as ScopedMunicipalityDoc[]
const pledgeAggregates = await aggregatePledgesByMunicipality(
payload,
municipalities.map((municipality) => municipality.id),
)
return { municipalities, pledgeAggregates }
}
/**
* Request-scoped municipality scope (accessible docs + pledge aggregates),
* deduplicated with React `cache()`: the staff dashboard and the map bundle
* both need the same scope on one request and used to load it twice.
*
* `payload` (module singleton) and `user` (from the request-cached
* `getCampaignUser`) are reference-stable within a request; the `where` clause
* participates via its canonical JSON string.
*/
const loadScopeCached = cache((payload: Payload, user: CampaignUser, whereKey: string) =>
loadScope(payload, user, JSON.parse(whereKey) as Where),
)
export const loadMunicipalityScope = (
payload: Payload,
user: CampaignUser,
where: Where,
): Promise<MunicipalityScope> => loadScopeCached(payload, user, JSON.stringify(where))

View file

@ -9,6 +9,19 @@ type Collection = keyof Config['collections']
export const getDocuments = <Slug extends Collection>(collection: Slug, depth?: number) =>
getPayload({ config: configPromise }).then((payload) => payload.find({ collection, depth }))
/** Bare petition ids (no relationship population) — for `generateStaticParams`. */
export const getPetitionIds = async (): Promise<string[]> => {
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'petition',
depth: 0,
limit: 0,
pagination: false,
select: {},
})
return result.docs.map((doc) => String(doc.id))
}
export const getDocumentById = <Slug extends Collection>(
collection: Slug,
id: string | number,

View file

@ -0,0 +1,7 @@
/**
* Cache tag for every `unstable_cache` entry derived from the immutable TSE
* election collections (2014/2018/2022). The data only changes when the seed
* is re-run (`pnpm db:seed:tse`), so the runbook is: re-seed → `POST
* /api/revalidate?tag=election-tse` (see revalidateRequest.ts allowlist).
*/
export const ELECTION_TSE_CACHE_TAG = 'election-tse'

View file

@ -1,4 +1,6 @@
import type { Payload } from 'payload'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
import {
BASELINE_TICKET_2022,
@ -7,6 +9,7 @@ import {
} from '@/lib/electionResults'
import type { CampaignUser, User } from '@/payload-types'
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
import { ELECTION_TSE_CACHE_TAG } from '@/utilities/electionCache'
export type FederalCandidateOption = {
candidateNumber: number
@ -15,17 +18,32 @@ export type FederalCandidateOption = {
totalVotesState: number
}
/** Cross-request cached core — immutable 2022 registry, busted with `election-tse`. */
const loadFederalCandidateOptionsCached = unstable_cache(
async (limit: number): Promise<FederalCandidateOption[]> => {
const payload = await getPayload({ config: configPromise })
return queryFederalCandidateOptions(limit, payload)
},
['federal-candidate-options'],
{ tags: [ELECTION_TSE_CACHE_TAG] },
)
/**
* Federal-deputy candidates available for comparison (2022 registry ordered by
* statewide votes). Solla himself is excluded — he is always the reference.
*/
export const loadFederalCandidateOptions = async (
payload: Payload,
export const loadFederalCandidateOptions = (
user: CampaignUser | User,
limit = 60,
): Promise<FederalCandidateOption[]> => {
assertCanReadElectionData(user)
return loadFederalCandidateOptionsCached(limit)
}
const queryFederalCandidateOptions = async (
limit: number,
payload: Awaited<ReturnType<typeof getPayload>>,
): Promise<FederalCandidateOption[]> => {
const result = await payload.find({
collection: 'electionCandidate',
where: {

View file

@ -1,4 +1,6 @@
import type { Payload } from 'payload'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload, type Payload } from 'payload'
import {
BASELINE_TICKET_2022,
@ -8,6 +10,7 @@ import {
} 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,
@ -28,8 +31,20 @@ export type CandidateComparisonRow = {
* inside one municipality geography, across the TSE series years. This is the
* coordination's core analysis tool ("com quem comparar nesta Praça?").
*/
export const loadMunicipalityCandidateComparison = async (
payload: Payload,
/** 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[],
@ -43,6 +58,14 @@ export const loadMunicipalityCandidateComparison = async (
.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',

View file

@ -1,4 +1,6 @@
import type { Payload } from 'payload'
import configPromise from '@payload-config'
import { unstable_cache } from 'next/cache'
import { getPayload, type Payload } from 'payload'
import {
BASELINE_TICKET_2022,
@ -8,6 +10,7 @@ import {
} 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,
@ -35,17 +38,38 @@ export type MunicipalityElectoralBaseline = {
}
}
/**
* Cross-request cached core — the TSE collections are immutable, so entries
* live until the `election-tse` tag is busted after a re-seed. The geography
* argument is part of the cache key (JSON-serialized by `unstable_cache`).
*/
const loadMunicipalityElectoralBaselineCached = unstable_cache(
async (
geography: MunicipalityElectionGeography,
): Promise<MunicipalityElectoralBaseline | null> => {
const payload = await getPayload({ config: configPromise })
return queryMunicipalityElectoralBaseline(payload, geography)
},
['municipality-electoral-baseline'],
{ tags: [ELECTION_TSE_CACHE_TAG] },
)
/**
* Candidate (Solla) vote series 2014/2018/2022 + 2022 turnout inside the
* municipality geography, plus 2022 president/governor ticket votes.
*/
export const loadMunicipalityElectoralBaseline = async (
payload: Payload,
export const loadMunicipalityElectoralBaseline = (
user: ElectionReader,
geography: MunicipalityElectionGeography,
): Promise<MunicipalityElectoralBaseline | null> => {
assertCanReadElectionData(user)
return loadMunicipalityElectoralBaselineCached(geography)
}
const queryMunicipalityElectoralBaseline = async (
payload: Payload,
geography: MunicipalityElectionGeography,
): Promise<MunicipalityElectoralBaseline | null> => {
const [candidateVotes, tallies, presidentVotes, governorVotes] = await Promise.all([
payload.find({
collection: 'electionCandidateVote',

View file

@ -0,0 +1,62 @@
import { HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
import type { MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
import type { VoteEstimateScenario } from '@/utilities/voteEstimate'
/**
* Client-safe contract of the municipality map: the bundle shape the server
* assembles and the year/scale vocabulary the client panel renders. Keep this
* module free of Payload/Next-server imports — client components import these
* VALUES, so anything reachable from here ships in the browser bundle
* (`municipalityMapData.ts` is the server-only side).
*/
export const MUNICIPALITY_MAP_YEARS = [...HISTORICAL_SERIES_YEARS, 2026] as const
export type MunicipalityMapYear = (typeof MUNICIPALITY_MAP_YEARS)[number]
export type MunicipalityMapScaleMode = 'absolute' | 'percentValid'
export const MUNICIPALITY_MAP_SCALE_MODES = [
'percentValid',
'absolute',
] as const satisfies readonly MunicipalityMapScaleMode[]
export const municipalityMapScaleModeLabels: Record<MunicipalityMapScaleMode, string> = {
absolute: 'Total (votos)',
percentValid: '% dos válidos',
}
export const municipalityMapYearLabels: Record<MunicipalityMapYear, string> = {
2014: '2014 (TSE)',
2018: '2018 (TSE)',
2022: '2022 (TSE)',
2026: '2026 (estimativas)',
}
export type MunicipalityZoneBreakdownRow = {
slug: string
name: string
votesByYear: Record<string, number>
votes2026ByScenario: Record<VoteEstimateScenario, number>
}
export type MunicipalityMapComparison = {
candidateNumber: number
candidateName: string
/** TSE year (as string) → codarea → (sollaVotes − otherVotes). */
diffByYear: Record<string, Record<string, number>>
}
export type MunicipalityMapBundle = {
/** year (as string) → codarea → value. 2026 = cenário médio (central). */
valuesByYear: Record<string, Record<string, number>>
/** 2026 totals per estimate scenario (codarea → votes). */
values2026ByScenario: Record<VoteEstimateScenario, Record<string, number>>
/** year (as string) → codarea → votosValidos (federal T1). 2026 reuses 2022. */
validVotesByYear: Record<string, Record<string, number>>
/** IBGE codarea → accessible municipality slugs for map click navigation. */
municipalitiesByIbgeCode: MunicipalitiesByIbgeCode
/** Zone municipalities in scope (Salvador) with per-year values. */
zoneBreakdown: MunicipalityZoneBreakdownRow[]
candidateName: string
comparison: MunicipalityMapComparison | null
}

View file

@ -1,18 +1,25 @@
import 'server-only'
import type { Payload } from 'payload'
import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates'
import { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import type { CampaignUser } from '@/payload-types'
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
import {
municipalityElectionGeography,
type MunicipalityElectionGeography,
} from '@/utilities/municipalityElectionGeography'
import {
loadCandidateVotesByCityZone,
loadValidVotesByCityZone,
sumVotesForGeography,
} from '@/utilities/municipalityElectoralBaseline'
import { buildMunicipalitiesByIbgeCode, type MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
import type {
MunicipalityMapBundle,
MunicipalityMapComparison,
} from '@/utilities/municipalityMapContract'
import { buildMunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
import {
buildMunicipalityListWhere,
parseMunicipalityListParams,
@ -25,64 +32,21 @@ import {
type VoteEstimateScenario,
} from '@/utilities/voteEstimate'
import {
aggregatePledgesByMunicipality,
emptyMunicipalityPledgeAggregate,
resolveMunicipalityStaffVoteTotal,
type MunicipalityPledgeAggregate,
} from '@/utilities/votePledgeData'
export type { MunicipalityMapSlugEntry, MunicipalitiesByIbgeCode } from '@/utilities/municipalityMapNavigation'
export const PLAZA_MAP_YEARS = [...HISTORICAL_SERIES_YEARS, 2026] as const
export type MunicipalityMapYear = (typeof PLAZA_MAP_YEARS)[number]
export type MunicipalityMapScaleMode = 'absolute' | 'percentValid'
export const PLAZA_MAP_SCALE_MODES = [
'percentValid',
'absolute',
] as const satisfies readonly MunicipalityMapScaleMode[]
export const municipalityMapScaleModeLabels: Record<MunicipalityMapScaleMode, string> = {
absolute: 'Total (votos)',
percentValid: '% dos válidos',
}
export const municipalityMapYearLabels: Record<MunicipalityMapYear, string> = {
2014: '2014 (TSE)',
2018: '2018 (TSE)',
2022: '2022 (TSE)',
2026: '2026 (estimativas)',
}
export type MunicipalityZoneBreakdownRow = {
slug: string
name: string
votesByYear: Record<string, number>
votes2026ByScenario: Record<VoteEstimateScenario, number>
}
export type MunicipalityMapComparison = {
candidateNumber: number
candidateName: string
/** TSE year (as string) → codarea → (sollaVotes − otherVotes). */
diffByYear: Record<string, Record<string, number>>
}
export type MunicipalityMapBundle = {
/** year (as string) → codarea → value. 2026 = cenário médio (central). */
valuesByYear: Record<string, Record<string, number>>
/** 2026 totals per estimate scenario (codarea → votes). */
values2026ByScenario: Record<VoteEstimateScenario, Record<string, number>>
/** year (as string) → codarea → votosValidos (federal T1). 2026 reuses 2022. */
validVotesByYear: Record<string, Record<string, number>>
/** IBGE codarea → accessible municipality slugs for map click navigation. */
municipalitiesByIbgeCode: MunicipalitiesByIbgeCode
/** Zone municipalities in scope (Salvador/Camaçari) with per-year values. */
zoneBreakdown: MunicipalityZoneBreakdownRow[]
candidateName: string
comparison: MunicipalityMapComparison | null
}
// Client components import the map vocabulary from the contract module; the
// server side re-exports it so data callers have one import surface.
export type {
MunicipalityMapBundle,
MunicipalityMapComparison,
MunicipalityMapScaleMode,
MunicipalityMapYear,
MunicipalityZoneBreakdownRow,
} from '@/utilities/municipalityMapContract'
export type ScopedMunicipality = {
id: number
@ -128,25 +92,6 @@ export const scopeMunicipalitiesFromDocs = (docs: ReadonlyArray<ScopedMunicipali
]
})
const loadScopedMunicipalities = async (
payload: Payload,
user: CampaignUser,
state: MunicipalityListState,
): Promise<ScopedMunicipality[]> => {
const result = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
select: { slug: true, name: true, kind: true, ibgeCode: true, expectedVotes: true },
where: buildMunicipalityListWhere(state),
user,
overrideAccess: false,
})
return scopeMunicipalitiesFromDocs(result.docs)
}
export const buildMunicipalityMapBundleFromMunicipalities = async (
payload: Payload,
user: CampaignUser,
@ -159,29 +104,17 @@ export const buildMunicipalityMapBundleFromMunicipalities = async (
const valuesByYear: Record<string, Record<string, number>> = {}
const validVotesByYear: Record<string, Record<string, number>> = {}
const zoneVotesBySlug = new Map<string, Record<string, number>>()
const sollaVotesByYear = new Map<number, Map<string, number>>()
const yearLoads = await Promise.all(
HISTORICAL_SERIES_YEARS.map(async (year) => {
const [votesByCityZone, validByCityZone] = await Promise.all([
loadCandidateVotesByCityZone(payload, user, {
year,
candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber,
}),
loadValidVotesByCityZone(payload, user, { year }),
])
return { year, votesByCityZone, validByCityZone }
}),
)
for (const { year, votesByCityZone, validByCityZone } of yearLoads) {
sollaVotesByYear.set(year, votesByCityZone)
// Historical years come from the committed artifact (immutable TSE data,
// pre-aggregated per municipality) — zero database work.
for (const year of HISTORICAL_SERIES_YEARS) {
const values: Record<string, number> = {}
const validValues: Record<string, number> = {}
for (const municipality of municipalities) {
const votes = sumVotesForGeography(votesByCityZone, municipality.geography)
const baseline = getMunicipalityFederalBaseline(municipality.slug)
const votes = baseline.votesByYear[String(year)] ?? 0
values[municipality.ibgeCode] = (values[municipality.ibgeCode] ?? 0) + votes
const valid = sumVotesForGeography(validByCityZone, municipality.geography)
const valid = baseline.validVotesByYear[String(year)] ?? 0
validValues[municipality.ibgeCode] = (validValues[municipality.ibgeCode] ?? 0) + valid
if (municipality.kind === 'zona') {
const bySlug = zoneVotesBySlug.get(municipality.slug) ?? {}
@ -269,10 +202,10 @@ export const buildMunicipalityMapBundleFromMunicipalities = async (
year,
candidateNumber: compareCandidate,
})
const sollaVotes = sollaVotesByYear.get(year) ?? new Map<string, number>()
const values: Record<string, number> = {}
for (const municipality of municipalities) {
const solla = sumVotesForGeography(sollaVotes, municipality.geography)
const solla =
getMunicipalityFederalBaseline(municipality.slug).votesByYear[String(year)] ?? 0
const other = sumVotesForGeography(otherVotes, municipality.geography)
values[municipality.ibgeCode] = (values[municipality.ibgeCode] ?? 0) + (solla - other)
}
@ -313,13 +246,15 @@ export const loadMunicipalityMapBundle = async (
if (user.role === 'leader') return null
const state = parseMunicipalityListParams(searchParams)
const municipalities = await loadScopedMunicipalities(payload, user, state)
const scope = await loadMunicipalityScope(payload, user, buildMunicipalityListWhere(state))
const municipalities = scopeMunicipalitiesFromDocs(scope.municipalities)
if (municipalities.length === 0) return null
const pledgeAggregates = await aggregatePledgesByMunicipality(
return buildMunicipalityMapBundleFromMunicipalities(
payload,
municipalities.map((municipality) => municipality.id),
user,
state,
municipalities,
scope.pledgeAggregates,
)
return buildMunicipalityMapBundleFromMunicipalities(payload, user, state, municipalities, pledgeAggregates)
}

View file

@ -3,6 +3,7 @@ import type { Payload } from 'payload'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import type { CampaignUser, Municipality } from '@/payload-types'
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
import {
buildMunicipalityListWhere,
municipalityPageSize,
@ -20,22 +21,10 @@ import { relationshipId } from '@/utilities/relationship'
import { loadStateDeputySummaries } from '@/utilities/stateDeputyData'
import { type VoteEstimateScenario } from '@/utilities/voteEstimate'
import {
aggregatePledgesByMunicipality,
rollupMunicipalityStaffVotes,
type MunicipalityPledgeAggregate,
} from '@/utilities/votePledgeData'
const municipalityListFilteredSelect = {
id: true,
slug: true,
name: true,
kind: true,
ibgeCode: true,
expectedVotes: true,
advisors: true,
priority: true,
} as const
export class MunicipalityNotFoundError extends Error {
override name = 'MunicipalityNotFoundError'
@ -79,7 +68,7 @@ export const loadMunicipalityListPageBundle = async (
}
}
const [paginatedResult, scopeCount, filteredResult] = await Promise.all([
const [paginatedResult, scopeCount, staffScope] = await Promise.all([
payload.find({
collection: 'municipality',
depth: 0,
@ -97,40 +86,24 @@ export const loadMunicipalityListPageBundle = async (
user,
overrideAccess: false,
}),
isStaff
? payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
where,
select: municipalityListFilteredSelect,
user,
overrideAccess: false,
})
: Promise.resolve(null),
// Request-scoped shared load (docs + pledge aggregates in one place).
isStaff ? loadMunicipalityScope(payload, user, where) : Promise.resolve(null),
])
let overview: MunicipalityListOverviewData | null = null
let pledgeAggregates = new Map<number, MunicipalityPledgeAggregate>()
if (filteredResult && filteredResult.docs.length > 0) {
pledgeAggregates = await aggregatePledgesByMunicipality(
payload,
filteredResult.docs.map((municipality) => municipality.id),
)
if (isStaff) {
const rollup = rollupMunicipalityStaffVotes(filteredResult.docs, pledgeAggregates)
overview = {
municipalityCount: filteredResult.docs.length,
staffVoteTotalByScenario: { ...rollup.staffVoteTotalByScenario },
pledgeCount: rollup.pledgeCount,
missingEstimateCount: rollup.missingEstimateCount,
withAdvisorCount: filteredResult.docs.filter(
(municipality) => (municipality.advisors ?? []).length > 0,
).length,
}
if (staffScope && staffScope.municipalities.length > 0) {
pledgeAggregates = staffScope.pledgeAggregates
const rollup = rollupMunicipalityStaffVotes(staffScope.municipalities, pledgeAggregates)
overview = {
municipalityCount: staffScope.municipalities.length,
staffVoteTotalByScenario: { ...rollup.staffVoteTotalByScenario },
pledgeCount: rollup.pledgeCount,
missingEstimateCount: rollup.missingEstimateCount,
withAdvisorCount: staffScope.municipalities.filter(
(municipality) => (municipality.advisors ?? []).length > 0,
).length,
}
}

View file

@ -1,10 +1,15 @@
import { ELECTION_TSE_CACHE_TAG } from '@/utilities/electionCache'
import { getGlobalCacheTag } from '@/utilities/globals'
export const REVALIDATE_POSTS_TAG = 'posts' as const
export const REVALIDATE_PRIVACY_POLICY_CACHE_TAG = getGlobalCacheTag('privacy-policy')
const ALLOWED_REVALIDATE_TAGS = [REVALIDATE_POSTS_TAG, REVALIDATE_PRIVACY_POLICY_CACHE_TAG] as const
const ALLOWED_REVALIDATE_TAGS = [
REVALIDATE_POSTS_TAG,
REVALIDATE_PRIVACY_POLICY_CACHE_TAG,
ELECTION_TSE_CACHE_TAG,
] as const
export type AllowedRevalidateTag = (typeof ALLOWED_REVALIDATE_TAGS)[number]

View file

@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import {
FEDERAL_BASELINE_CANDIDATE_NUMBER,
federalBaselineMunicipalitySlugs,
getMunicipalityFederalBaseline,
} from '@/lib/bahiaElectionAggregates'
import { BASELINE_TICKET_2022, HISTORICAL_SERIES_YEARS } from '@/lib/electionResults'
import { municipalityCatalog } from '@/lib/municipalityCatalog'
/**
* Pins the committed election-aggregate artifact to the municipality catalog.
* If either changes shape (new remodel, new TSE scope), regenerate with
* `pnpm build:election-aggregates` — a drift here means the map would render
* zeros silently.
*/
describe('election aggregates artifact', () => {
it('covers exactly the municipality catalog slugs', () => {
const artifactSlugs = new Set(federalBaselineMunicipalitySlugs())
const catalogSlugs = new Set(municipalityCatalog.map((entry) => entry.slug))
expect(artifactSlugs.size).toBe(catalogSlugs.size)
for (const slug of catalogSlugs) {
expect(artifactSlugs.has(slug)).toBe(true)
}
})
it('is generated for the baseline ticket candidate', () => {
expect(FEDERAL_BASELINE_CANDIDATE_NUMBER).toBe(BASELINE_TICKET_2022.candidate.candidateNumber)
})
it('has every historical year populated with plausible statewide totals', () => {
for (const year of HISTORICAL_SERIES_YEARS) {
let candidateTotal = 0
let validTotal = 0
for (const slug of federalBaselineMunicipalitySlugs()) {
const baseline = getMunicipalityFederalBaseline(slug)
const votes = baseline.votesByYear[String(year)]
const valid = baseline.validVotesByYear[String(year)]
expect(votes).toBeTypeOf('number')
expect(valid).toBeTypeOf('number')
expect(votes).toBeGreaterThanOrEqual(0)
expect(valid).toBeGreaterThanOrEqual(votes > 0 ? 1 : 0)
candidateTotal += votes ?? 0
validTotal += valid ?? 0
}
// A statewide federal-deputy campaign always lands in the 5-7 digit range.
expect(candidateTotal).toBeGreaterThan(10_000)
expect(validTotal).toBeGreaterThan(candidateTotal)
}
})
it('returns an empty baseline for unknown slugs', () => {
const baseline = getMunicipalityFederalBaseline('nao-existe')
expect(baseline.votesByYear).toEqual({})
expect(baseline.validVotesByYear).toEqual({})
})
})