mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
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:
parent
0e4e6855dc
commit
a32ba3b4f0
22 changed files with 5757 additions and 185 deletions
111
scripts/build-election-aggregates.mjs
Normal file
111
scripts/build-election-aggregates.mjs
Normal 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)
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
44
src/lib/bahiaElectionAggregates.ts
Normal file
44
src/lib/bahiaElectionAggregates.ts
Normal 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
|
||||
5232
src/lib/electionAggregates/bahia-federal-baseline.json
Normal file
5232
src/lib/electionAggregates/bahia-federal-baseline.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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',
|
||||
|
|
|
|||
74
src/utilities/campaignMunicipalityScope.ts
Normal file
74
src/utilities/campaignMunicipalityScope.ts
Normal 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))
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
7
src/utilities/electionCache.ts
Normal file
7
src/utilities/electionCache.ts
Normal 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'
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
62
src/utilities/municipalityMapContract.ts
Normal file
62
src/utilities/municipalityMapContract.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
58
tests/unit/bahiaElectionAggregates.unit.spec.ts
Normal file
58
tests/unit/bahiaElectionAggregates.unit.spec.ts
Normal 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({})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue