teqo/src/utilities/municipalityGoalAccount.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

143 lines
5.3 KiB
TypeScript

import 'server-only'
import { cache } from 'react'
import type { Payload } from 'payload'
import type { CampaignUser, User } from '@/payload-types'
import { loadCampaignGoals } from '@/utilities/campaignGoals'
import {
aggregateGoalCoverage,
computeGoalCoverage,
computeGoalCoverageByScenario,
type MunicipalityGoalCoverage,
} from '@/utilities/goalCoverage'
import {
computeMunicipalityPotential,
computeStatewideSuggestedGoals,
type MunicipalityPotential,
type SuggestedGoalByScenario,
} from '@/utilities/municipalityPotential'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
VOTE_ESTIMATE_SCENARIOS,
zeroByVoteEstimateScenario,
type VoteEstimateScenarioFields,
type VoteEstimateScenario,
} from '@/lib/voteEstimate'
import { emptyMunicipalityPledgeAggregate, type MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
type GoalAccountReader = CampaignUser | User
/**
* E8 "conta da cadeira" — request-scoped statewide suggested-goal ladder
* (`campaignGoals` global + `computeStatewideSuggestedGoals`), degrau 1 da
* caching ladder. Every surface (dashboard, list overview, list rows, detail
* card) needs the SAME goals — the optimistic scenario's growth factor is
* `stateGoal / Σ base` over the full 435-slug catalog, so this always runs
* over the whole catalog, never a filtered scope, and is
* `cache()`-deduplicated so dashboard + list + detail don't each recompute it
* on requests that touch more than one.
*/
const loadSuggestedGoalsCached = cache(
async (payload: Payload, user: GoalAccountReader) => {
const goals = await loadCampaignGoals(payload, user)
return computeStatewideSuggestedGoals(goals)
},
)
export type MunicipalityGoalCoverageBundle = {
coverageByMunicipalityID: Map<number, Record<VoteEstimateScenario, MunicipalityGoalCoverage>>
/** Sum of every municipality's coverage in `municipalities`, per scenario (dashboard/overview metric). */
aggregateByScenario: Record<VoteEstimateScenario, MunicipalityGoalCoverage>
}
/**
* Per-scenario goal coverage for a scope of municipalities (dashboard total,
* or the list's filtered overview) plus the per-municipality breakdown the
* list rows pick a scenario from client-side — mirrors
* `rollupMunicipalityStaffVotes`'s "compute all 3 scenarios server-side, pick
* client-side" shape.
*/
export const loadMunicipalityGoalCoverageBundle = async (
payload: Payload,
user: GoalAccountReader,
municipalities: ReadonlyArray<{
id: number
slug: string
expectedVotes?: VoteEstimateScenarioFields | null
}>,
pledgeAggregates: Map<number, MunicipalityPledgeAggregate>,
): Promise<MunicipalityGoalCoverageBundle> => {
const { suggestedGoalBySlug } = await loadSuggestedGoalsCached(payload, user)
const coverageByMunicipalityID = new Map<
number,
Record<VoteEstimateScenario, MunicipalityGoalCoverage>
>()
for (const municipality of municipalities) {
const pledgeAggregate =
pledgeAggregates.get(municipality.id) ?? emptyMunicipalityPledgeAggregate
const suggestedGoalByScenario =
suggestedGoalBySlug.get(municipality.slug) ?? emptySuggestedGoalByScenario
coverageByMunicipalityID.set(
municipality.id,
computeGoalCoverageByScenario(
municipality.expectedVotes,
suggestedGoalByScenario,
pledgeAggregate,
),
)
}
const coverages = [...coverageByMunicipalityID.values()]
const aggregateByScenario = {} as Record<VoteEstimateScenario, MunicipalityGoalCoverage>
for (const scenario of VOTE_ESTIMATE_SCENARIOS) {
aggregateByScenario[scenario] = aggregateGoalCoverage(
coverages.map((coverageByScenario) => coverageByScenario[scenario]),
)
}
return { coverageByMunicipalityID, aggregateByScenario }
}
/** Unknown slug (never in the catalog): zero goal in every scenario, never mutated. */
const emptySuggestedGoalByScenario: Readonly<SuggestedGoalByScenario> = zeroByVoteEstimateScenario()
export type MunicipalityGoalAccount = {
/** Fixed to the `central` scenario, like `goalCoverage` below. */
suggestedGoal: number
/** Fixed to the `central` scenario — the detail page has no scenario selector. */
goalCoverage: MunicipalityGoalCoverage
potential: MunicipalityPotential
}
/**
* One município's full "conta da cadeira": suggested goal, coverage (central
* scenario), and the diagnostic potential block (teto do campo, captura,
* share intracampo, roll-off) — the detail page's "Conta da cadeira" card.
*/
export const loadMunicipalityGoalAccount = async (
payload: Payload,
user: GoalAccountReader,
municipality: { slug: string; expectedVotes?: VoteEstimateScenarioFields | null },
pledgeAggregate: MunicipalityPledgeAggregate,
): Promise<MunicipalityGoalAccount> => {
const { suggestedGoalBySlug, potentialBySlug } = await loadSuggestedGoalsCached(payload, user)
const suggestedGoalByScenario =
suggestedGoalBySlug.get(municipality.slug) ?? emptySuggestedGoalByScenario
const goalCoverage = computeGoalCoverage(
municipality.expectedVotes,
suggestedGoalByScenario,
pledgeAggregate,
DEFAULT_VOTE_ESTIMATE_SCENARIO,
)
const potential =
potentialBySlug.get(municipality.slug) ?? computeMunicipalityPotential(municipality.slug)
return {
suggestedGoal: suggestedGoalByScenario[DEFAULT_VOTE_ESTIMATE_SCENARIO],
goalCoverage,
potential,
}
}