) : null}
+ {view.classification ? (
+
+ Classificação:{' '}
+
+ {formatElectionNumber(view.classification.distribution.defesa)} defesa
+
+ {' · '}
+
+ {formatElectionNumber(view.classification.distribution.indecisa)} indecisa
+
+ {' · '}
+
+ {formatElectionNumber(view.classification.distribution.ataque)} ataque
+
+ {' · '}
+
+ {formatElectionNumber(view.classification.distribution.perdida)} perdida
+
+
+ ) : null}
diff --git a/src/lib/electionInsights.ts b/src/lib/electionInsights.ts
index 204853a3..9636f545 100644
--- a/src/lib/electionInsights.ts
+++ b/src/lib/electionInsights.ts
@@ -16,6 +16,15 @@ export const CONVERSION_REDUTO_MIN = 0.4
/** Share of federal nominal votes outside the chapa field → flip opportunity (A5 Fase 2). */
export const RIGHT_SHARE_THRESHOLD = 0.25
+/** At or above this share of valid federal votes → defesa (literature reference, A5-2). */
+export const TERRITORIAL_DEFESA_MIN = 0.35
+
+/** At or above this share → indecisa (between defesa and ataque). */
+export const TERRITORIAL_INDECISA_MIN = 0.2
+
+/** At or above this share → ataque; below → perdida. */
+export const TERRITORIAL_ATAQUE_MIN = 0.1
+
export type VoteTrendStatus = 'decline' | 'stable' | 'increase' | 'noBaseline'
export type VoteTrendSeries = {
@@ -72,6 +81,35 @@ export type ConversionBandDistribution = {
oportunidade: number
}
+export type TerritorialClassificationBand =
+ | 'defesa'
+ | 'ataque'
+ | 'indecisa'
+ | 'perdida'
+ | 'semBaseline'
+
+export type ComparableTerritorialClass = Exclude
+
+export type TerritorialClassificationInput = {
+ sollaVotes: number
+ federalValidVotes: number | null
+}
+
+export type TerritorialClassificationResult = {
+ percentValid: number | null
+ band: TerritorialClassificationBand
+ title: string
+ priorityLine: string
+ supportLine: string | null
+}
+
+export type TerritorialClassificationDistribution = {
+ defesa: number
+ ataque: number
+ indecisa: number
+ perdida: number
+}
+
/** Minimal baseline shape for Gap vs 2022 (full detail VM is assignable). */
export type GapVs2022Baseline = {
candidate: { votes: number }
@@ -122,6 +160,84 @@ export const isComparableConversionBand = (
): band is 'reduto' | 'consolidado' | 'oportunidade' =>
band === 'reduto' || band === 'consolidado' || band === 'oportunidade'
+const emptyTerritorialClassificationDistribution =
+ (): TerritorialClassificationDistribution => ({
+ defesa: 0,
+ ataque: 0,
+ indecisa: 0,
+ perdida: 0,
+ })
+
+export const aggregateTerritorialClass = (
+ bands: readonly TerritorialClassificationBand[],
+): TerritorialClassificationDistribution => {
+ const distribution = emptyTerritorialClassificationDistribution()
+ for (const band of bands) {
+ if (isComparableTerritorialClass(band)) {
+ distribution[band] += 1
+ }
+ }
+ return distribution
+}
+
+export const isComparableTerritorialClass = (
+ band: TerritorialClassificationBand,
+): band is ComparableTerritorialClass =>
+ band === 'defesa' || band === 'ataque' || band === 'indecisa' || band === 'perdida'
+
+const TERRITORIAL_CLASS_UI = {
+ defesa: {
+ label: 'Defesa',
+ badgeVariant: 'estimate-confirmed',
+ alertVariant: 'default',
+ title: 'Território de defesa',
+ priorityLine: 'Base sólida — prioridade: manter engajamento',
+ },
+ indecisa: {
+ label: 'Indecisa',
+ badgeVariant: 'estimate-pending',
+ alertVariant: 'pending',
+ title: 'Território indeciso',
+ priorityLine: 'Pulverizado — prioridade: consolidar',
+ },
+ ataque: {
+ label: 'Ataque',
+ badgeVariant: 'destructive',
+ alertVariant: 'destructive',
+ title: 'Território de ataque',
+ priorityLine: 'Desfavorável relevante — prioridade: virar/reduzir margem',
+ },
+ perdida: {
+ label: 'Perdida',
+ badgeVariant: 'secondary',
+ alertVariant: 'default',
+ title: 'Território perdido',
+ priorityLine: 'Baixa base histórica — prioridade: minimizar perda',
+ },
+} as const satisfies Record<
+ ComparableTerritorialClass,
+ {
+ label: string
+ badgeVariant: 'estimate-confirmed' | 'estimate-pending' | 'destructive' | 'secondary'
+ alertVariant: 'default' | 'pending' | 'destructive'
+ title: string
+ priorityLine: string
+ }
+>
+
+export const territorialClassLabel = (band: TerritorialClassificationBand): string =>
+ isComparableTerritorialClass(band) ? TERRITORIAL_CLASS_UI[band].label : 'Sem baseline'
+
+export const territorialClassBadgeVariant = (
+ band: TerritorialClassificationBand,
+): (typeof TERRITORIAL_CLASS_UI)[ComparableTerritorialClass]['badgeVariant'] =>
+ isComparableTerritorialClass(band) ? TERRITORIAL_CLASS_UI[band].badgeVariant : 'secondary'
+
+export const territorialClassAlertVariant = (
+ band: TerritorialClassificationBand,
+): (typeof TERRITORIAL_CLASS_UI)[ComparableTerritorialClass]['alertVariant'] =>
+ isComparableTerritorialClass(band) ? TERRITORIAL_CLASS_UI[band].alertVariant : 'default'
+
export const conversionRateAlertVariant = (band: ConversionRateBand): 'default' | 'pending' =>
band === 'oportunidade' ? 'pending' : 'default'
@@ -666,3 +782,45 @@ export const ticketLeverageAlertVariant = (
isComparableTicketLeverage(leverage.status) && (leverage.headlinePercent ?? 0) < 100
? 'pending'
: 'default'
+
+export const computeTerritorialClass = ({
+ sollaVotes,
+ federalValidVotes,
+}: TerritorialClassificationInput): TerritorialClassificationResult => {
+ if (federalValidVotes === null || federalValidVotes <= 0) {
+ return {
+ percentValid: null,
+ band: 'semBaseline',
+ title: 'Classificação territorial',
+ priorityLine:
+ federalValidVotes === null
+ ? NO_ELECTION_BASELINE_MESSAGE
+ : 'Sem votos válidos no baseline para esta geografia',
+ supportLine: null,
+ }
+ }
+
+ const percentValid = sollaVotes / federalValidVotes
+ const percent = Math.round(percentValid * 100)
+
+ let band: ComparableTerritorialClass
+ if (percentValid >= TERRITORIAL_DEFESA_MIN) {
+ band = 'defesa'
+ } else if (percentValid >= TERRITORIAL_INDECISA_MIN) {
+ band = 'indecisa'
+ } else if (percentValid >= TERRITORIAL_ATAQUE_MIN) {
+ band = 'ataque'
+ } else {
+ band = 'perdida'
+ }
+
+ const { title, priorityLine } = TERRITORIAL_CLASS_UI[band]
+
+ return {
+ percentValid,
+ band,
+ title,
+ priorityLine,
+ supportLine: `${percent}% dos votos válidos federais em 2022 (${formatElectionNumber(sollaVotes)} / ${formatElectionNumber(federalValidVotes)})`,
+ }
+}
diff --git a/src/utilities/nucleusElectoralBaseline.ts b/src/utilities/nucleusElectoralBaseline.ts
index 0b175986..65e9d330 100644
--- a/src/utilities/nucleusElectoralBaseline.ts
+++ b/src/utilities/nucleusElectoralBaseline.ts
@@ -14,20 +14,24 @@ import {
} from '@/lib/electionResults'
import {
aggregateConversionBand,
+ aggregateTerritorialClass,
aggregateTicketFlipOverview,
aggregateTicketLeverageOverview,
aggregateVoteTrend,
computeConversionRate,
computeGapVs2022,
+ computeTerritorialClass,
computeTicketFlipOpportunity,
computeTicketLeverage,
- isComparableTicketLeverage,
+ isComparableConversionBand,
+ isComparableTerritorialClass,
type ConversionBandDistribution,
type ConversionRateBand,
- isComparableConversionBand,
+ type TerritorialClassificationBand,
+ type TerritorialClassificationDistribution,
type TicketFlipOpportunityResult,
- type TicketLeverageOverviewAggregate,
type TicketFlipOverviewAggregate,
+ type TicketLeverageOverviewAggregate,
type VoteTrendSeries,
type VoteTrendStatus,
} from '@/lib/electionInsights'
@@ -382,21 +386,23 @@ const aggregateElectorate = (
return { aptos, validos, brancos, nulos, abstencoes }
}
-const sumElectorateForGeography = (
+const sumFederalTallyForGeography = (
talliesByCityZone: ReadonlyMap,
geography: NucleusElectionGeography,
-): Pick => {
+): Pick => {
let aptos = 0
let abstencoes = 0
+ let validos = 0
for (const { cityName, zoneNumber } of geography.cityZonePairs) {
const row = talliesByCityZone.get(cityZoneKey(cityName, zoneNumber))
if (!row) continue
aptos += row.aptos
abstencoes += row.abstencoes
+ validos += row.votosValidos
}
- return { aptos, abstencoes }
+ return { aptos, abstencoes, validos }
}
export const aggregateNucleusElectoralBaseline = (
@@ -786,12 +792,17 @@ export type NucleusConversionOverviewAggregate = {
distribution: ConversionBandDistribution
}
+export type NucleusClassificationOverviewAggregate = {
+ distribution: TerritorialClassificationDistribution
+}
+
export type NucleusListElectionOverview = {
baseline2022: NucleusBaseline2022OverviewAggregate | null
trend: NucleusTrendOverviewAggregate | null
conversion: NucleusConversionOverviewAggregate | null
leverage: TicketLeverageOverviewAggregate | null
flipOpportunity: TicketFlipOverviewAggregate | null
+ classification: NucleusClassificationOverviewAggregate | null
}
/**
@@ -808,7 +819,14 @@ export const loadNucleusListElectionOverview = async (
)
if (withGeographyIndexes.length === 0) {
- return { baseline2022: null, trend: null, conversion: null, leverage: null, flipOpportunity: null }
+ return {
+ baseline2022: null,
+ trend: null,
+ conversion: null,
+ leverage: null,
+ flipOpportunity: null,
+ classification: null,
+ }
}
const comparableIndexes: number[] = []
@@ -819,15 +837,26 @@ export const loadNucleusListElectionOverview = async (
const unionGeography = buildUnionGeography(geographies, withGeographyIndexes)
if (!unionGeography) {
- return { baseline2022: null, trend: null, conversion: null, leverage: null, flipOpportunity: null }
+ return {
+ baseline2022: null,
+ trend: null,
+ conversion: null,
+ leverage: null,
+ flipOpportunity: null,
+ classification: null,
+ }
}
- const [seriesByYear, ticketVotes, majoritarianTallies] = await Promise.all([
+ const [seriesByYear, ticketVotes, majoritarianTallies, classificationTallies] = await Promise.all([
loadCandidateSeriesByGeography(payload, user, unionGeography),
loadTicketOfficeVotes(payload, user, unionGeography),
loadMajoritarianElectionTallies(payload, user, unionGeography),
+ loadElectionTallies(payload, user, unionGeography),
])
const votes2022 = seriesByYear.get(ELECTION_YEAR_2022) ?? new Map()
+ const classificationTalliesByCityZone = new Map(
+ classificationTallies.map((row) => [cityZoneKey(row.cityName, row.zoneNumber), row]),
+ )
const trend = aggregateVoteTrend(
withGeographyIndexes.flatMap((index) => {
@@ -836,6 +865,24 @@ export const loadNucleusListElectionOverview = async (
}),
)
+ const classificationBands: TerritorialClassificationBand[] = []
+ for (const index of withGeographyIndexes) {
+ const geography = geographies[index]!
+ const { validos } = sumFederalTallyForGeography(classificationTalliesByCityZone, geography)
+ const territorial = computeTerritorialClass({
+ sollaVotes: sumCandidateVotesForGeography(geography, votes2022),
+ federalValidVotes: validos,
+ })
+ if (isComparableTerritorialClass(territorial.band)) {
+ classificationBands.push(territorial.band)
+ }
+ }
+
+ const classification: NucleusClassificationOverviewAggregate | null =
+ classificationBands.length > 0
+ ? { distribution: aggregateTerritorialClass(classificationBands) }
+ : null
+
const flipResults = await Promise.all(
withGeographyIndexes.map(async (index) => {
const geography = geographies[index]!
@@ -852,17 +899,10 @@ export const loadNucleusListElectionOverview = async (
conversion: null,
leverage: null,
flipOpportunity,
+ classification,
}
}
- const conversionUnion = buildUnionGeography(geographies, comparableIndexes)
- const tallies = conversionUnion
- ? await loadElectionTallies(payload, user, conversionUnion)
- : []
- const talliesByCityZone = new Map(
- tallies.map((row) => [cityZoneKey(row.cityName, row.zoneNumber), row]),
- )
-
let estimateSum = 0
let candidateVotesSum = 0
let comparableCount = 0
@@ -888,15 +928,18 @@ export const loadNucleusListElectionOverview = async (
comparableCount += 1
}
- const electorate = sumElectorateForGeography(talliesByCityZone, geography)
+ const { aptos, abstencoes } = sumFederalTallyForGeography(
+ classificationTalliesByCityZone,
+ geography,
+ )
const conversion = computeConversionRate({
- aptos: electorate.aptos,
- abstencoes: electorate.abstencoes,
+ aptos,
+ abstencoes,
confirmedVoteEstimate: estimate,
})
if (isComparableConversionBand(conversion.band)) {
conversionEstimateSum += estimate
- conversionAptosSum += electorate.aptos
+ conversionAptosSum += aptos
conversionBands.push(conversion.band)
}
@@ -929,6 +972,7 @@ export const loadNucleusListElectionOverview = async (
conversion,
leverage: aggregateTicketLeverageOverview(leverageRows),
flipOpportunity,
+ classification,
}
}
diff --git a/src/utilities/nucleusListOverviewPageData.ts b/src/utilities/nucleusListOverviewPageData.ts
index 4a5054e5..e8099ce5 100644
--- a/src/utilities/nucleusListOverviewPageData.ts
+++ b/src/utilities/nucleusListOverviewPageData.ts
@@ -178,6 +178,7 @@ export const loadNucleusListOverviewData = async (
conversion: listElectionOverview.conversion,
leverage: listElectionOverview.leverage,
flipOpportunity: listElectionOverview.flipOpportunity,
+ classification: listElectionOverview.classification,
choropleth,
})
}
diff --git a/src/utilities/nucleusListOverviewViewModels.ts b/src/utilities/nucleusListOverviewViewModels.ts
index af7cc790..639b621b 100644
--- a/src/utilities/nucleusListOverviewViewModels.ts
+++ b/src/utilities/nucleusListOverviewViewModels.ts
@@ -2,7 +2,12 @@ import type { NucleusPriority } from '@/lib/schemas/nucleus'
import type { TicketFlipOverviewAggregate, TicketLeverageOverviewAggregate } from '@/lib/electionInsights'
import type { CampaignUser, NucleusUpdate } from '@/payload-types'
import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview'
-import type { NucleusBaseline2022OverviewAggregate, NucleusConversionOverviewAggregate, NucleusTrendOverviewAggregate } from '@/utilities/nucleusElectoralBaseline'
+import type {
+ NucleusBaseline2022OverviewAggregate,
+ NucleusClassificationOverviewAggregate,
+ NucleusConversionOverviewAggregate,
+ NucleusTrendOverviewAggregate,
+} from '@/utilities/nucleusElectoralBaseline'
import type { NucleusChoroplethBundle } from '@/utilities/nucleusChoroplethTypes'
import {
aggregateVoteGoals,
@@ -60,6 +65,8 @@ export type NucleusListOverviewViewModel = {
leverage: TicketLeverageOverviewAggregate | null
/** Null when no nucleus in the filtered set has resolvable TSE geography. */
flipOpportunity: TicketFlipOverviewAggregate | null
+ /** Null when no nucleus in the filtered set has resolvable geography with valid votes. */
+ classification: NucleusClassificationOverviewAggregate | null
recentUpdates: NucleusListOverviewUpdateRecord[]
upcomingActionPlans: ActionPlanUpcomingPreviewRecord[]
choropleth: NucleusChoroplethBundle
@@ -78,6 +85,7 @@ export const buildNucleusListOverviewViewModel = ({
conversion = null,
leverage = null,
flipOpportunity = null,
+ classification = null,
choropleth,
}: {
nuclei: NucleusListOverviewNucleusRecord[]
@@ -89,6 +97,7 @@ export const buildNucleusListOverviewViewModel = ({
conversion?: NucleusConversionOverviewAggregate | null
leverage?: TicketLeverageOverviewAggregate | null
flipOpportunity?: TicketFlipOverviewAggregate | null
+ classification?: NucleusClassificationOverviewAggregate | null
choropleth: NucleusChoroplethBundle
}): NucleusListOverviewViewModel => {
const totalFiltered = nuclei.length
@@ -127,6 +136,7 @@ export const buildNucleusListOverviewViewModel = ({
conversion,
leverage,
flipOpportunity,
+ classification,
recentUpdates,
upcomingActionPlans,
choropleth,
diff --git a/tests/int/nucleusListElectionOverview.int.spec.ts b/tests/int/nucleusListElectionOverview.int.spec.ts
index d1165d51..c8c666de 100644
--- a/tests/int/nucleusListElectionOverview.int.spec.ts
+++ b/tests/int/nucleusListElectionOverview.int.spec.ts
@@ -134,6 +134,9 @@ describe('loadNucleusListElectionOverview (E7 F2)', () => {
baseline2022: null,
trend: null,
conversion: null,
+ leverage: null,
+ flipOpportunity: null,
+ classification: null,
})
})
diff --git a/tests/unit/campaignNucleusListOverview.unit.spec.ts b/tests/unit/campaignNucleusListOverview.unit.spec.ts
index d51bff28..7f13cea3 100644
--- a/tests/unit/campaignNucleusListOverview.unit.spec.ts
+++ b/tests/unit/campaignNucleusListOverview.unit.spec.ts
@@ -231,4 +231,23 @@ describe('NucleusListOverview', () => {
expect(html).toContain('3 queda')
expect(html).not.toContain('Com série histórica')
})
+
+ it('renders the territorial classification distribution when aggregate data is present', () => {
+ const view = buildNucleusListOverviewViewModel({
+ role: 'geral',
+ nuclei: [nucleus({ id: 1 })],
+ recentUpdates: [],
+ upcomingActionPlans: [],
+ classification: { distribution: { defesa: 4, indecisa: 2, ataque: 1, perdida: 3 } },
+ choropleth: emptyChoropleth,
+ })
+
+ const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now }))
+
+ expect(html).toContain('Classificação:')
+ expect(html).toContain('4 defesa')
+ expect(html).toContain('2 indecisa')
+ expect(html).toContain('1 ataque')
+ expect(html).toContain('3 perdida')
+ })
})
diff --git a/tests/unit/electionInsights.unit.spec.ts b/tests/unit/electionInsights.unit.spec.ts
index f87ab0c2..ea632665 100644
--- a/tests/unit/electionInsights.unit.spec.ts
+++ b/tests/unit/electionInsights.unit.spec.ts
@@ -4,15 +4,20 @@ import { describe, expect, it } from 'vitest'
import {
aggregateConversionBand,
+ aggregateTerritorialClass,
aggregateVoteTrend,
comparableTrendCount,
computeConversionRate,
computeGapVs2022,
+ computeTerritorialClass,
computeVoteTrend,
CONVERSION_OPPORTUNITY_MAX,
CONVERSION_REDUTO_MIN,
isComparableConversionBand,
type GapVs2022Baseline,
+ TERRITORIAL_ATAQUE_MIN,
+ TERRITORIAL_DEFESA_MIN,
+ TERRITORIAL_INDECISA_MIN,
} from '@/lib/electionInsights'
const baseline = (candidateVotes: number): GapVs2022Baseline => ({
@@ -188,3 +193,89 @@ describe('isComparableConversionBand', () => {
expect(isComparableConversionBand('semEstimativa')).toBe(false)
})
})
+
+describe('computeTerritorialClass', () => {
+ it('classifies defesa at 35% of valid votes', () => {
+ const validos = 2490
+ expect(
+ computeTerritorialClass({
+ sollaVotes: Math.ceil(validos * TERRITORIAL_DEFESA_MIN),
+ federalValidVotes: validos,
+ }),
+ ).toMatchObject({
+ band: 'defesa',
+ title: 'Território de defesa',
+ })
+ })
+
+ it('classifies indecisa at 20% of valid votes', () => {
+ expect(
+ computeTerritorialClass({
+ sollaVotes: Math.floor(2490 * TERRITORIAL_INDECISA_MIN),
+ federalValidVotes: 2490,
+ }),
+ ).toMatchObject({
+ band: 'indecisa',
+ title: 'Território indeciso',
+ })
+ })
+
+ it('classifies ataque at 10% of valid votes', () => {
+ expect(
+ computeTerritorialClass({
+ sollaVotes: Math.floor(2490 * TERRITORIAL_ATAQUE_MIN),
+ federalValidVotes: 2490,
+ }),
+ ).toMatchObject({
+ band: 'ataque',
+ title: 'Território de ataque',
+ })
+ })
+
+ it('classifies perdida below 10%', () => {
+ expect(
+ computeTerritorialClass({
+ sollaVotes: Math.floor(2490 * (TERRITORIAL_ATAQUE_MIN - 0.001)),
+ federalValidVotes: 2490,
+ }),
+ ).toMatchObject({
+ band: 'perdida',
+ title: 'Território perdido',
+ })
+ })
+
+ it('reports semBaseline when validos is zero', () => {
+ expect(computeTerritorialClass({ sollaVotes: 100, federalValidVotes: 0 })).toMatchObject({
+ band: 'semBaseline',
+ percentValid: null,
+ })
+ })
+
+ it('reports semBaseline when validos is null', () => {
+ expect(computeTerritorialClass({ sollaVotes: 100, federalValidVotes: null })).toMatchObject({
+ band: 'semBaseline',
+ })
+ })
+})
+
+describe('aggregateTerritorialClass', () => {
+ it('counts comparable territorial bands only', () => {
+ expect(
+ aggregateTerritorialClass(['defesa', 'ataque', 'indecisa', 'perdida', 'semBaseline']),
+ ).toEqual({
+ defesa: 1,
+ ataque: 1,
+ indecisa: 1,
+ perdida: 1,
+ })
+ })
+
+ it('returns all zeros when every band is semBaseline', () => {
+ expect(aggregateTerritorialClass(['semBaseline', 'semBaseline'])).toEqual({
+ defesa: 0,
+ ataque: 0,
+ indecisa: 0,
+ perdida: 0,
+ })
+ })
+})
diff --git a/tests/unit/nucleusElectoralBaselineUi.unit.spec.ts b/tests/unit/nucleusElectoralBaselineUi.unit.spec.ts
index 09adab71..0b7ab89b 100644
--- a/tests/unit/nucleusElectoralBaselineUi.unit.spec.ts
+++ b/tests/unit/nucleusElectoralBaselineUi.unit.spec.ts
@@ -88,7 +88,7 @@ describe('NucleusInsights', () => {
expect(html).toContain('Tendência:')
})
- it('renders ticket leverage and flip opportunity insights', () => {
+ it('renders ticket leverage, flip opportunity, and territorial classification insights', () => {
const html = renderToStaticMarkup(
createElement(NucleusInsights, {
baseline,
@@ -100,5 +100,10 @@ describe('NucleusInsights', () => {
expect(html).toContain('data-insight="ticket-flip"')
expect(html).toContain('completar a chapa')
expect(html).not.toMatch(/direita/i)
+ expect(html).toContain('data-insight="territorial-class"')
+ expect(html).toContain('Território indeciso')
+ expect(html).toContain('Pulverizado — prioridade: consolidar')
+ expect(html).toContain('Indecisa')
+ expect(html).toContain('34% dos votos válidos federais em 2022')
})
})