feat(potencial): cálculo puro, loader e rotas públicas da seção

sectionPotential.ts parseia/valida o shard (ordem fail-closed), busca por
seção e computa o cenário aprovado — válidos = nominais, X₁ = comparecimento
− Lula − Flávio (todos os terceiros; o bulk tem 13 candidaturas) e X₂ =
aptos − Lula − Flávio — com formatadores pt-BR e o nome do PNG. O loader
server-only lê os shards por fs com cache por processo; as rotas públicas
/api/potencial/{municipios,secao} aplicam same-origin, rate-limit e zod
strict, com 404 honesto e cache de um dia. O story 1080×1920 é desenhado
por um renderer puro com contexto estrutural. 'potencial' entra nos slugs
reservados e o módulo novo no pin do top-level de utilities.
This commit is contained in:
Francisco Solla 2026-10-07 19:04:34 -03:00
parent 6aad284e11
commit 62b674cfba
12 changed files with 1360 additions and 0 deletions

View file

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server'
import { potentialMunicipiosQuerySchema } from '@/lib/schemas/sectionPotentialQuery'
import { listPotentialMunicipalities } from '@/lib/sectionPotential'
import {
checkContentEventRateLimit,
contentEventClientKey,
} from '@/utilities/content/contentEventRateLimit'
import { loadPotentialUfShard } from '@/utilities/potencialSections'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
/**
* S46 — the municípios of one UF for the title form's combobox. Public read of
* committed TSE geography: same-origin, rate-limited like the other anonymous
* JSON routes, strict query. The answer is lean (`code` + `name`), alphabetical
* and cacheable for a day — the artifact only changes with a deploy.
*/
const json = (body: unknown, status = 200, cacheable = false): NextResponse =>
NextResponse.json(body, {
status,
headers: cacheable
? { 'Cache-Control': 'public, max-age=86400' }
: { 'Cache-Control': 'no-store' },
})
export const GET = async (request: Request): Promise<NextResponse> => {
if (!isSameOriginRequest(request)) return json({ ok: false, error: 'origin' }, 403)
const clientKey = contentEventClientKey(request.headers)
if (!checkContentEventRateLimit(clientKey && `potencial:${clientKey}`)) {
return json({ ok: false, error: 'rate-limited' }, 429)
}
const parsed = potentialMunicipiosQuerySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
)
if (!parsed.success) return json({ ok: false, error: 'query' }, 400)
const shard = await loadPotentialUfShard(parsed.data.uf)
if (!shard) return json({ ok: false, error: 'not-found' }, 404)
return json({ ok: true, municipalities: listPotentialMunicipalities(shard) }, 200, true)
}

View file

@ -0,0 +1,54 @@
import { NextResponse } from 'next/server'
import { potentialSectionQuerySchema } from '@/lib/schemas/sectionPotentialQuery'
import { findPotentialSection } from '@/lib/sectionPotential'
import {
checkContentEventRateLimit,
contentEventClientKey,
} from '@/utilities/content/contentEventRateLimit'
import { loadPotentialUfShard } from '@/utilities/potencialSections'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const dynamic = 'force-dynamic'
/**
* S46 — the raw numbers of one polling section (TSE 1st round 2026): identity
* plus aptos/comparecimento/Lula/Flávio/válidos. The percentages are computed
* by the shared pure module on the client, so the panel and the story image
* can never drift. Unknown UF or section answers 404 — the page's honest
* "não encontramos essa seção" state; a section with no valid votes is a 200
* whose `no-data` verdict belongs to the pure calculator.
*/
const json = (body: unknown, status = 200, cacheable = false): NextResponse =>
NextResponse.json(body, {
status,
headers: cacheable
? { 'Cache-Control': 'public, max-age=86400' }
: { 'Cache-Control': 'no-store' },
})
export const GET = async (request: Request): Promise<NextResponse> => {
if (!isSameOriginRequest(request)) return json({ ok: false, error: 'origin' }, 403)
const clientKey = contentEventClientKey(request.headers)
if (!checkContentEventRateLimit(clientKey && `potencial:${clientKey}`)) {
return json({ ok: false, error: 'rate-limited' }, 429)
}
const parsed = potentialSectionQuerySchema.safeParse(
Object.fromEntries(new URL(request.url).searchParams),
)
if (!parsed.success) return json({ ok: false, error: 'query' }, 400)
const shard = await loadPotentialUfShard(parsed.data.uf)
if (!shard) return json({ ok: false, error: 'not-found' }, 404)
const section = findPotentialSection(shard, {
municipalityCode: parsed.data.codigo,
zone: parsed.data.zona,
section: parsed.data.secao,
})
if (!section) return json({ ok: false, error: 'not-found' }, 404)
return json({ ok: true, section }, 200, true)
}

View file

@ -0,0 +1,21 @@
import { z } from 'zod'
import { POTENTIAL_UFS } from '@/lib/sectionPotential'
/**
* S46 — wire contract of the public `/api/potencial` reads. Strict objects:
* an extra query key is malformed, never silently ignored. The section numbers
* follow the title's shapes (zona 2–4 dígitos, seção até 4) and are normalized
* by the callers; the município is the TSE code the combobox picked.
*/
export const potentialMunicipiosQuerySchema = z.strictObject({
uf: z.enum(POTENTIAL_UFS),
})
export const potentialSectionQuerySchema = z.strictObject({
uf: z.enum(POTENTIAL_UFS),
/** Código TSE do município (`CD_MUNICIPIO`) — não é slug de URL. */
codigo: z.coerce.number().int().positive(),
zona: z.coerce.number().int().min(1).max(9999),
secao: z.coerce.number().int().min(1).max(9999),
})

299
src/lib/sectionPotential.ts Normal file
View file

@ -0,0 +1,299 @@
/**
* S46 — the pure core of the public `/potencial` story: parse the committed
* per-UF TSE shard, find one section and compute the approved scenario.
*
* The shard (`public/dados/potencial-secao-2026/<UF>.json`, built by
* `scripts/build-potencial-secoes-2026.mjs`) stores one row per polling
* section — `[zona, seção, aptos, comparecimento, lula, flavio, válidos]` —
* sorted by (zona, seção) inside each município, municípios sorted by code.
* The numbers are raw; every percentage is computed here so the result panel
* and the story image can never drift.
*
* Scenario (aprovado no plano de intenção, corte "todos os votos disponíveis"):
* - 1º turno = Lula ÷ válidos × 100;
* - X₁ = comparecimento − Lula − Flávio (nulos + brancos + TODOS os terceiros);
* - X₂ = X₁ + faltantes = aptos − Lula − Flávio;
* - 2º turno = (Lula + X) ÷ (Lula + X + Flávio) × 100, para X₁ e X₂;
* - ganho = 2º − 1º, em pontos percentuais (nunca arredondado antes de subtrair).
*
* Fail-closed: a section without valid votes (or with impossible counts) is
* `no-data`, never a fabricated estimate.
*/
import { slugify } from '@/lib/slug'
import { matchesNormalizedAtWordStart, normalizeSearchPhrase } from '@/lib/wordStartFilter'
export const POTENTIAL_SHARD_VERSION = 1
export const POTENTIAL_UFS = [
'AC',
'AL',
'AP',
'AM',
'BA',
'CE',
'DF',
'ES',
'GO',
'MA',
'MT',
'MS',
'MG',
'PA',
'PB',
'PR',
'PE',
'PI',
'RJ',
'RN',
'RS',
'RO',
'RR',
'SC',
'SP',
'SE',
'TO',
] as const
export type PotentialUf = (typeof POTENTIAL_UFS)[number]
/** `[zona, seção, aptos, comparecimento, votos Lula, votos Flávio, válidos]`. */
type PotentialSectionRow = readonly [number, number, number, number, number, number, number]
type PotentialMunicipality = {
/** TSE `CD_MUNICIPIO`. */
code: number
name: string
sections: PotentialSectionRow[]
}
export type PotentialUfShard = {
version: number
uf: PotentialUf
municipalities: PotentialMunicipality[]
}
/** The identity + raw numbers of one found section — the API's wire shape. */
export type PotentialSectionNumbers = {
uf: PotentialUf
municipalityCode: number
municipalityName: string
zone: number
section: number
aptos: number
comparecimento: number
lula: number
flavio: number
validos: number
}
export type SectionPotential = {
/** 1º turno: Lula ÷ votos válidos × 100. */
firstRoundPct: number
/** 2º turno só com X₁ (votos disponíveis já no 1º turno). */
immediatePct: number
/** 2º turno com X₂ (X₁ + faltantes). */
totalPct: number
gainImmediatePp: number
gainTotalPp: number
x1: number
x2: number
}
export type SectionPotentialResult =
| { available: true; potential: SectionPotential }
| { available: false; reason: 'no-data' }
const isCount = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
const isPotentialUf = (value: string): value is PotentialUf =>
(POTENTIAL_UFS as readonly string[]).includes(value)
/**
* Structural validation of one shard. Runs once per UF per process (the loader
* caches the parsed result) and fails closed: a malformed row — or one out of
* the (code) / (zona, seção) order the binary searches assume — rejects the
* whole shard instead of leaking a partial one.
*/
export const parsePotentialShard = (value: unknown): PotentialUfShard | null => {
if (typeof value !== 'object' || value === null) return null
const shard = value as Record<string, unknown>
if (shard.version !== POTENTIAL_SHARD_VERSION) return null
if (typeof shard.uf !== 'string' || !isPotentialUf(shard.uf)) return null
if (!Array.isArray(shard.municipalities)) return null
const municipalities: PotentialMunicipality[] = []
let lastCode = -1
for (const entry of shard.municipalities) {
if (typeof entry !== 'object' || entry === null) return null
const municipality = entry as Record<string, unknown>
if (!isCount(municipality.code) || typeof municipality.name !== 'string') return null
if (municipality.code <= lastCode) return null
lastCode = municipality.code
if (!Array.isArray(municipality.sections)) return null
const sections: PotentialSectionRow[] = []
let lastZone = -1
let lastSection = -1
for (const row of municipality.sections) {
if (!Array.isArray(row) || row.length !== 7 || !row.every(isCount)) return null
const [zone, section] = row as unknown as PotentialSectionRow
if (zone < lastZone || (zone === lastZone && section <= lastSection)) return null
lastZone = zone
lastSection = section
sections.push(row as unknown as PotentialSectionRow)
}
municipalities.push({ code: municipality.code, name: municipality.name, sections })
}
return { version: POTENTIAL_SHARD_VERSION, uf: shard.uf, municipalities }
}
/** Município lookup by TSE code — the shard is sorted by code (binary search). */
const findPotentialMunicipality = (
shard: PotentialUfShard,
code: number,
): PotentialMunicipality | null => {
const municipalities = shard.municipalities
let low = 0
let high = municipalities.length - 1
while (low <= high) {
const middle = (low + high) >> 1
const current = municipalities[middle]
if (current.code === code) return current
if (current.code < code) low = middle + 1
else high = middle - 1
}
return null
}
/** Section row lookup inside one município — rows sorted by (zona, seção). */
const findPotentialSectionRow = (
municipality: PotentialMunicipality,
zone: number,
section: number,
): PotentialSectionRow | null => {
const rows = municipality.sections
let low = 0
let high = rows.length - 1
while (low <= high) {
const middle = (low + high) >> 1
const row = rows[middle]
if (row[0] === zone && row[1] === section) return row
if (row[0] < zone || (row[0] === zone && row[1] < section)) low = middle + 1
else high = middle - 1
}
return null
}
/** The full query: shard → município → section → raw numbers, or `null`. */
export const findPotentialSection = (
shard: PotentialUfShard,
query: { municipalityCode: number; zone: number; section: number },
): PotentialSectionNumbers | null => {
const municipality = findPotentialMunicipality(shard, query.municipalityCode)
if (!municipality) return null
const row = findPotentialSectionRow(municipality, query.zone, query.section)
if (!row) return null
return {
uf: shard.uf,
municipalityCode: municipality.code,
municipalityName: municipality.name,
zone: row[0],
section: row[1],
aptos: row[2],
comparecimento: row[3],
lula: row[4],
flavio: row[5],
validos: row[6],
}
}
/**
* The approved scenario over one section's raw numbers. `no-data` is the only
* alternative to a computed result: zero valid votes or impossible counts
* (Flávio + Lula above the comparecimento, válidos above it, turnout above the
* electorate) never render a percentage.
*/
export const computeSectionPotential = (
numbers: Pick<
PotentialSectionNumbers,
'aptos' | 'comparecimento' | 'lula' | 'flavio' | 'validos'
>,
): SectionPotentialResult => {
const { aptos, comparecimento, lula, flavio, validos } = numbers
if (validos <= 0) return { available: false, reason: 'no-data' }
if (
comparecimento > aptos ||
validos > comparecimento ||
lula + flavio > comparecimento ||
lula > validos ||
flavio > validos
) {
return { available: false, reason: 'no-data' }
}
const x1 = comparecimento - lula - flavio
const x2 = aptos - lula - flavio
const firstRoundPct = (lula / validos) * 100
// O denominador do 2º turno é (Lula + X + Flávio): X₁ fecha no comparecimento
// e X₂ fecha no eleitorado — a conta do cenário "só Lula × Flávio".
const immediatePct = ((lula + x1) / comparecimento) * 100
const totalPct = ((lula + x2) / aptos) * 100
return {
available: true,
potential: {
firstRoundPct,
immediatePct,
totalPct,
gainImmediatePp: immediatePct - firstRoundPct,
gainTotalPp: totalPct - firstRoundPct,
x1,
x2,
},
}
}
const percentFormatter = new Intl.NumberFormat('pt-BR', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
/** `66,923…` → `66,9%` (one decimal, pt-BR). */
export const formatPotentialPercent = (value: number): string =>
`${percentFormatter.format(value)}%`
/** `6,864…` → `+6,9 p.p.`; the sign is derived, never hardcoded. */
export const formatPotentialPoints = (value: number): string =>
`${value < 0 ? '−' : '+'}${percentFormatter.format(Math.abs(value))} p.p.`
/** The downloadable PNG name: legible, ASCII, deterministic. */
export const sectionStoryFileName = (numbers: PotentialSectionNumbers): string =>
`potencial-lula-${numbers.uf.toLowerCase()}-${slugify(numbers.municipalityName)}-z${numbers.zone}-s${numbers.section}.png`
/** The municípios of one shard, lean and alphabetical — the combobox source. */
export type PotentialMunicipalityOption = { code: number; name: string }
export const listPotentialMunicipalities = (
shard: PotentialUfShard,
): PotentialMunicipalityOption[] =>
shard.municipalities
.map(({ code, name }) => ({ code, name }))
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
/**
* The municípios whose normalized name matches `term` at a word start
* (accent/punctuation-insensitive — o mesmo normalizador dos pickers do repo);
* below three letters the list stays closed (design: "digite ao menos 3").
*/
export const filterPotentialMunicipalities = (
municipalities: PotentialMunicipalityOption[],
term: string,
): PotentialMunicipalityOption[] => {
const needle = normalizeSearchPhrase(term)
if (needle.length < 3) return []
return municipalities.filter((municipality) =>
matchesNormalizedAtWordStart(normalizeSearchPhrase(municipality.name), needle),
)
}

View file

@ -0,0 +1,456 @@
/**
* S46 — the pure canvas renderer of the story 1080×1920. The design artifact
* (`docs/plans/potencial-secao-story-ui-design.html`, cena 4) defines the
* geometry in container-query units over a 1080-wide story; every constant
* below is that value × 10.8. The caller passes the numbers already formatted
* by `sectionPotential`, so the image and the page can never disagree.
*
* The context is a structural subset of `CanvasRenderingContext2D` so unit
* tests drive a fake recorder instead of a real canvas.
*/
export const STORY_WIDTH = 1080
export const STORY_HEIGHT = 1920
/**
* The story's own copy, shared with the page where the sentence is the same
* (the headline and the hypothesis): a single source, so the screen and the
* image can never disagree on what the campaign is saying.
*/
export const SECTION_STORY_TITLE = 'Quanto o Lula pode crescer na sua seção?'
export const SECTION_STORY_HYPOTHESIS =
'todos os votos disponíveis vão para o Lula e o Flávio mantém os votos dele.'
const COLORS = {
background: '#f7f6f3',
ink: '#1b1b1b',
title: '#141414',
muted: '#57534c',
kicker: '#8c8880',
rowNumber: '#a39e95',
rowSmall: '#837e76',
legend: '#6e6a63',
hairline: '#dcd9d2',
linkBorder: '#cbc7bf',
} as const
/** cqw → px on the 1080-wide story (1cqw = 10.8px). */
const PX = {
padX: 84,
padTop: 118,
padBottom: 108,
kickerSize: 23,
kickerSpacing: 2.5,
titleSize: 58,
titleLineHeight: 1.16,
titleSpacing: -0.5,
titleMarginTop: 26,
subSize: 27,
subLineHeight: 1.45,
subMarginTop: 22,
fieldsMarginTop: 39,
fieldPaddingY: 14,
fieldGap: 20,
numWidth: 38,
numSize: 27,
nameSize: 29,
nameLineHeight: 1.18,
smallSize: 21,
smallLineHeight: 1.3,
smallMarginTop: 3,
valueSize: 34,
valueSmallSize: 27,
valueHeroSize: 48,
formulaMarginTop: 48,
formulaTitleSize: 22,
formulaTitleSpacing: 2,
calcSize: 25,
calcLineHeight: 1.45,
calcMarginTop: 13,
legendMarginTop: 26,
legendSize: 21,
legendLineHeight: 1.5,
legendPaddingTop: 18,
linkHeight: 100,
linkRadius: 10,
hairline: 1.5,
} as const
export type SectionStoryDrawContext = {
font: string
fillStyle: string | CanvasGradient | CanvasPattern
strokeStyle: string | CanvasGradient | CanvasPattern
lineWidth: number
textAlign: CanvasTextAlign
textBaseline: CanvasTextBaseline
letterSpacing: string
fillRect(x: number, y: number, width: number, height: number): void
fillText(text: string, x: number, y: number): void
measureText(text: string): { width: number }
save(): void
restore(): void
beginPath(): void
moveTo(x: number, y: number): void
lineTo(x: number, y: number): void
stroke(): void
setLineDash(segments: number[]): void
roundRect(x: number, y: number, width: number, height: number, radii?: number | number[]): void
}
export type SectionStoryInput = {
municipalityName: string
uf: string
zone: number
section: number
/** Já formatados por `sectionPotential` (1 casa, pt-BR). */
firstRound: string
immediate: string
total: string
gainImmediate: string
gainTotal: string
fontFamily: string
}
type StoryWord = { text: string; weight: number; color: string }
const setFont = (ctx: SectionStoryDrawContext, weight: number, size: number, family: string) => {
ctx.font = `${weight} ${size}px ${family}`
}
const wordsOf = (text: string, weight: number, color: string): StoryWord[] =>
text.split(' ').map((word) => ({ text: word, weight, color }))
const richWords = (segments: { text: string; weight?: number; color?: string }[], color: string) =>
segments.flatMap((segment) =>
wordsOf(segment.text, segment.weight ?? 400, segment.color ?? color),
)
/** Greedy word wrap over styled words; the caller sets the size/family. */
const wrapWords = (
ctx: SectionStoryDrawContext,
words: StoryWord[],
maxWidth: number,
size: number,
family: string,
): StoryWord[][] => {
const lines: StoryWord[][] = []
let line: StoryWord[] = []
let lineWidth = 0
const space = () => {
setFont(ctx, 400, size, family)
return ctx.measureText(' ').width
}
for (const word of words) {
setFont(ctx, word.weight, size, family)
const width = ctx.measureText(word.text).width
const next = line.length === 0 ? width : lineWidth + space() + width
if (line.length > 0 && next > maxWidth) {
lines.push(line)
line = [word]
lineWidth = width
} else {
line.push(word)
lineWidth = next
}
}
if (line.length > 0) lines.push(line)
return lines
}
const drawWords = (
ctx: SectionStoryDrawContext,
words: StoryWord[],
x: number,
y: number,
size: number,
family: string,
) => {
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
let cursor = x
for (const [index, word] of words.entries()) {
setFont(ctx, word.weight, size, family)
ctx.fillStyle = word.color
if (index > 0) cursor += ctx.measureText(' ').width
ctx.fillText(word.text, cursor, y)
cursor += ctx.measureText(word.text).width
}
}
const drawWrapped = (
ctx: SectionStoryDrawContext,
words: StoryWord[],
x: number,
y: number,
maxWidth: number,
size: number,
lineHeight: number,
family: string,
): number => {
const lines = wrapWords(ctx, words, maxWidth, size, family)
for (const [index, line] of lines.entries()) {
drawWords(ctx, line, x, y + index * size * lineHeight, size, family)
}
return y + lines.length * size * lineHeight
}
/** Truncates a wrapped text to `maxLines`, adding an ellipsis to the last one. */
const drawWrappedClamped = (
ctx: SectionStoryDrawContext,
words: StoryWord[],
x: number,
y: number,
maxWidth: number,
size: number,
lineHeight: number,
maxLines: number,
family: string,
): number => {
const lines = wrapWords(ctx, words, maxWidth, size, family)
const visible = lines.slice(0, maxLines)
if (lines.length > maxLines && visible.length > 0) {
const last = visible[visible.length - 1]
setFont(ctx, 400, size, family)
let text = last.map((word) => word.text).join(' ')
while (text.length > 0 && ctx.measureText(`${text}…`).width > maxWidth) {
text = text.slice(0, -1).trimEnd()
}
visible[visible.length - 1] = [{ ...last[0], text: `${text}…` }]
}
for (const [index, line] of visible.entries()) {
drawWords(ctx, line, x, y + index * size * lineHeight, size, family)
}
return y + visible.length * size * lineHeight
}
const drawHairline = (ctx: SectionStoryDrawContext, y: number, x0: number, x1: number) => {
ctx.save()
ctx.beginPath()
ctx.strokeStyle = COLORS.hairline
ctx.lineWidth = PX.hairline
ctx.setLineDash([])
ctx.moveTo(x0, y)
ctx.lineTo(x1, y)
ctx.stroke()
ctx.restore()
}
type StoryField = {
number: string
name: string
small: string
value: string
valueKind: 'small' | 'default' | 'hero'
}
/**
* Draws the whole story on a 1080×1920 context. Pure: no DOM, no fonts API —
* the caller must have loaded `fontFamily` (weights 400/500/600/700) first.
*/
export const drawSectionStory = (ctx: SectionStoryDrawContext, input: SectionStoryInput): void => {
const family = input.fontFamily
const left = PX.padX
const right = STORY_WIDTH - PX.padX
const maxWidth = right - left
ctx.save()
ctx.fillStyle = COLORS.background
ctx.fillRect(0, 0, STORY_WIDTH, STORY_HEIGHT)
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
let y: number = PX.padTop
ctx.letterSpacing = `${PX.kickerSpacing}px`
setFont(ctx, 500, PX.kickerSize, family)
ctx.fillStyle = COLORS.kicker
ctx.fillText('2º TURNO · 25/10', left, y)
y += PX.kickerSize * 1.3
ctx.letterSpacing = `${PX.titleSpacing}px`
y += PX.titleMarginTop
y = drawWrapped(
ctx,
wordsOf(SECTION_STORY_TITLE, 600, COLORS.title),
left,
y,
maxWidth,
PX.titleSize,
PX.titleLineHeight,
family,
)
ctx.letterSpacing = '0px'
y += PX.subMarginTop
y = drawWrapped(
ctx,
richWords(
[
{ text: 'Cenário só ' },
{ text: 'Lula × Flávio', weight: 600, color: '#2b2b2b' },
{ text: ` nesta seção: ${SECTION_STORY_HYPOTHESIS}` },
],
COLORS.muted,
),
left,
y,
maxWidth,
PX.subSize,
PX.subLineHeight,
family,
)
const fields: StoryField[] = [
{
number: '1.',
name: 'Sua seção',
small: `${input.municipalityName} · ${input.uf}`,
value: `Zona ${input.zone} · Seção ${input.section}`,
valueKind: 'small',
},
{
number: '2.',
name: 'Lula no 1º turno',
small: 'dos votos válidos na seção',
value: input.firstRound,
valueKind: 'default',
},
{
number: '3.',
name: 'Lula no 2º turno',
small: 'só votos imediatos (X₁)',
value: input.immediate,
valueKind: 'default',
},
{
number: '4.',
name: 'Lula no 2º turno',
small: 'todos os votos (X₂)',
value: input.total,
valueKind: 'default',
},
{
number: '5.',
name: 'Ganho imediato',
small: '2º − 1º, em pontos percentuais',
value: input.gainImmediate,
valueKind: 'default',
},
{
number: '6.',
name: 'Ganho total',
small: '2º − 1º, em pontos percentuais',
value: input.gainTotal,
valueKind: 'hero',
},
]
y += PX.fieldsMarginTop
drawHairline(ctx, y, left, right)
const labelX = left + PX.numWidth + PX.fieldGap
const labelMaxWidth = right - labelX - 220
for (const field of fields) {
const valueSize =
field.valueKind === 'hero'
? PX.valueHeroSize
: field.valueKind === 'small'
? PX.valueSmallSize
: PX.valueSize
const valueWeight = field.valueKind === 'hero' ? 700 : field.valueKind === 'small' ? 500 : 600
const rowTop = y + PX.fieldPaddingY
setFont(ctx, 500, PX.numSize, family)
ctx.fillStyle = COLORS.rowNumber
ctx.fillText(field.number, left, rowTop + 2)
const nameBottom = drawWrapped(
ctx,
wordsOf(field.name, 500, COLORS.ink),
labelX,
rowTop,
labelMaxWidth,
PX.nameSize,
PX.nameLineHeight,
family,
)
const smallBottom = drawWrappedClamped(
ctx,
wordsOf(field.small, 400, COLORS.rowSmall),
labelX,
nameBottom + PX.smallMarginTop,
labelMaxWidth,
PX.smallSize,
PX.smallLineHeight,
2,
family,
)
setFont(ctx, valueWeight, valueSize, family)
ctx.fillStyle = COLORS.title
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
const valueMiddle = rowTop + Math.max(nameBottom - rowTop, PX.nameSize * PX.nameLineHeight) / 2
ctx.fillText(field.value, right, valueMiddle)
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
const rowHeight = Math.max(smallBottom - rowTop, valueSize) + 2 * PX.fieldPaddingY
y += rowHeight
drawHairline(ctx, y, left, right)
}
y += PX.formulaMarginTop
ctx.letterSpacing = `${PX.formulaTitleSpacing}px`
setFont(ctx, 500, PX.formulaTitleSize, family)
ctx.fillStyle = COLORS.kicker
ctx.fillText('A CONTA (COMO NO TSE: % = VOTOS ÷ VOTOS VÁLIDOS)', left, y)
ctx.letterSpacing = '0px'
y += PX.formulaTitleSize * 1.4
for (const line of [
'2º turno (imediato) = (LULA + X₁) ÷ (LULA + X₁ + FLÁVIO) × 100',
'2º turno (total) = (LULA + X₂) ÷ (LULA + X₂ + FLÁVIO) × 100',
'ganho = 2º − 1º',
]) {
y += PX.calcMarginTop
setFont(ctx, 400, PX.calcSize, family)
ctx.fillStyle = COLORS.ink
ctx.fillText(line, left, y)
y += PX.calcSize * PX.calcLineHeight
}
y += PX.legendMarginTop
drawHairline(ctx, y, left, right)
y += PX.legendPaddingTop
drawWrapped(
ctx,
richWords(
[
{ text: 'X₁', weight: 600, color: COLORS.ink },
{ text: ' = nulos + brancos + votos de terceiros · ' },
{ text: 'X₂', weight: 600, color: COLORS.ink },
{ text: ' = X₁ + faltantes' },
],
COLORS.legend,
),
left,
y,
maxWidth,
PX.legendSize,
PX.legendLineHeight,
family,
)
const linkTop = STORY_HEIGHT - PX.padBottom - PX.linkHeight
ctx.save()
ctx.beginPath()
ctx.strokeStyle = COLORS.linkBorder
ctx.lineWidth = PX.hairline
ctx.setLineDash([8, 8])
ctx.roundRect(left, linkTop, maxWidth, PX.linkHeight, PX.linkRadius)
ctx.stroke()
ctx.restore()
ctx.restore()
}

View file

@ -16,6 +16,7 @@ export const SHARE_LINK_RESERVED_SLUGS = [
'jingles',
'conteudos',
'fotos',
'potencial',
'api',
'admin',
'noticia',

View file

@ -0,0 +1,31 @@
import 'server-only'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { parsePotentialShard, type PotentialUfShard } from '@/lib/sectionPotential'
/**
* S46 — the server-side reader of the committed TSE shards. The artifact lives
* under `public/` (copied into the standalone image by the Dockerfile) and is
* immutable per deploy, so one parse per UF per process is enough; a missing or
* malformed file answers `null` and every caller fails closed.
*/
const SHARD_DIR = join(process.cwd(), 'public', 'dados', 'potencial-secao-2026')
const shardCache = new Map<string, Promise<PotentialUfShard | null>>()
export const loadPotentialUfShard = (uf: string): Promise<PotentialUfShard | null> => {
const normalized = uf.trim().toUpperCase()
if (!/^[A-Z]{2}$/.test(normalized)) return Promise.resolve(null)
const cached = shardCache.get(normalized)
if (cached) return cached
const promise = readFile(join(SHARD_DIR, `${normalized}.json`), 'utf8')
.then((text) => parsePotentialShard(JSON.parse(text)))
.catch(() => null)
shardCache.set(normalized, promise)
return promise
}

View file

@ -0,0 +1,103 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { GET as getMunicipios } from '@/app/(frontend)/api/potencial/municipios/route'
import { GET as getSecao } from '@/app/(frontend)/api/potencial/secao/route'
/**
* S46 — the two public reads of `/potencial` over the committed TSE artifact:
* the município list of a UF and the raw numbers of one real section
* (Serrinha/BA ZE 150 seção 50, conferida contra o CSV do TSE). Guards:
* same-origin, strict query, rate limit and the honest 404.
*/
const request = (path: string, headers: Record<string, string> = {}): Request =>
new Request(`http://localhost${path}`, { headers })
describe('GET /api/potencial/municipios', () => {
it('lists the municípios of a UF, alphabetically, with a day cache', async () => {
const response = await getMunicipios(request('/api/potencial/municipios?uf=BA'))
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toContain('max-age=86400')
const body = (await response.json()) as {
ok: boolean
municipalities: { code: number; name: string }[]
}
expect(body.ok).toBe(true)
expect(body.municipalities).toHaveLength(417)
expect(body.municipalities).toContainEqual({ code: 39098, name: 'Serrinha' })
const names = body.municipalities.map((municipality) => municipality.name)
expect([...names].sort((left, right) => left.localeCompare(right, 'pt-BR'))).toEqual(names)
})
it('rejects a malformed query and a cross-origin request', async () => {
expect((await getMunicipios(request('/api/potencial/municipios?uf=ba'))).status).toBe(400)
expect((await getMunicipios(request('/api/potencial/municipios'))).status).toBe(400)
expect((await getMunicipios(request('/api/potencial/municipios?uf=BA&extra=1'))).status).toBe(
400,
)
expect(
(
await getMunicipios(
request('/api/potencial/municipios?uf=BA', { origin: 'https://evil.example' }),
)
).status,
).toBe(403)
})
})
describe('GET /api/potencial/secao', () => {
it('answers the raw numbers of a real section', async () => {
const response = await getSecao(
request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=50'),
)
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toContain('max-age=86400')
expect(await response.json()).toEqual({
ok: true,
section: {
uf: 'BA',
municipalityCode: 39098,
municipalityName: 'Serrinha',
zone: 150,
section: 50,
aptos: 376,
comparecimento: 289,
lula: 174,
flavio: 76,
validos: 260,
},
})
})
it('answers 404 for an unknown section and 400 for a malformed query', async () => {
expect(
(await getSecao(request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=9999')))
.status,
).toBe(404)
expect(
(await getSecao(request('/api/potencial/secao?uf=BA&codigo=1&zona=150&secao=50'))).status,
).toBe(404)
expect(
(await getSecao(request('/api/potencial/secao?uf=BA&codigo=39098&zona=0&secao=50')))
.status,
).toBe(400)
expect(
(await getSecao(request('/api/potencial/secao?uf=BA&codigo=39098&zona=150'))).status,
).toBe(400)
})
it('throttles a client that exceeds the window budget', async () => {
const headers = { 'x-forwarded-for': '203.0.113.9' }
for (let index = 0; index < 120; index += 1) {
const response = await getSecao(
request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=50', headers),
)
expect(response.status).toBe(200)
}
const throttled = await getSecao(
request('/api/potencial/secao?uf=BA&codigo=39098&zona=150&secao=50', headers),
)
expect(throttled.status).toBe(429)
})
})

View file

@ -553,6 +553,10 @@ describe('src/utilities top-level is pinned', () => {
'payloadTransaction.ts',
'postgresTransactionLocks.ts',
'posts.ts',
// S46 — server-side reader of the committed TSE section shards under
// `public/dados/potencial-secao-2026` (fs + parse cache, fail-closed);
// the sibling of `electionCache.ts` for the `/potencial` public route.
'potencialSections.ts',
'recentVisits.ts',
'revalidateRequest.ts',
'sameOriginRequest.ts',

View file

@ -0,0 +1,206 @@
import { describe, expect, it } from 'vitest'
import {
computeSectionPotential,
filterPotentialMunicipalities,
findPotentialSection,
formatPotentialPercent,
formatPotentialPoints,
parsePotentialShard,
sectionStoryFileName,
type PotentialSectionNumbers,
type PotentialUfShard,
} from '@/lib/sectionPotential'
const numbers = (overrides: Partial<PotentialSectionNumbers> = {}): PotentialSectionNumbers => ({
uf: 'BA',
municipalityCode: 39098,
municipalityName: 'Serrinha',
zone: 150,
section: 50,
aptos: 376,
comparecimento: 289,
lula: 174,
flavio: 76,
validos: 260,
...overrides,
})
describe('computeSectionPotential', () => {
it('refuses the plan illustrative reference (válidos above the comparecimento)', () => {
// O exemplo do plano ("Seção 0051 Curitiba/PR: Lula 91, Flávio 81, válidos
// 190, X₁=13, X₂=69") é uma referência de forma, marcada como "não usar na
// UI como dado real" — e é aritmeticamente impossível: comp = Lula+Flávio+X₁
// = 185 < válidos = 190. O cálculo falha fechado em vez de renderizar %.
const result = computeSectionPotential({
aptos: 241,
comparecimento: 185,
lula: 91,
flavio: 81,
validos: 190,
})
expect(result).toEqual({ available: false, reason: 'no-data' })
})
it('computes the real Serrinha/BA ZE 150 section 50 row', () => {
const result = computeSectionPotential(numbers())
expect(result.available).toBe(true)
if (!result.available) return
// X₁ = 289 − 174 − 76 = 39 (nulos + brancos + todos os terceiros);
// X₂ = 376 − 174 − 76 = 126 (X₁ + faltantes).
expect(result.potential.x1).toBe(39)
expect(result.potential.x2).toBe(126)
expect(formatPotentialPercent(result.potential.firstRoundPct)).toBe('66,9%')
expect(formatPotentialPercent(result.potential.immediatePct)).toBe('73,7%')
expect(formatPotentialPercent(result.potential.totalPct)).toBe('79,8%')
expect(formatPotentialPoints(result.potential.gainImmediatePp)).toBe('+6,8 p.p.')
expect(formatPotentialPoints(result.potential.gainTotalPp)).toBe('+12,9 p.p.')
})
it('never fabricates a percentage for a section without valid votes', () => {
expect(
computeSectionPotential({ aptos: 100, comparecimento: 0, lula: 0, flavio: 0, validos: 0 }),
).toEqual({ available: false, reason: 'no-data' })
})
it.each([
[
'Lula + Flávio above the comparecimento',
{ aptos: 400, comparecimento: 100, lula: 60, flavio: 60, validos: 100 },
],
[
'válidos above the comparecimento',
{ aptos: 400, comparecimento: 100, lula: 40, flavio: 40, validos: 120 },
],
[
'turnout above the electorate',
{ aptos: 90, comparecimento: 100, lula: 40, flavio: 40, validos: 80 },
],
[
'a candidate above the valid votes',
{ aptos: 400, comparecimento: 300, lula: 250, flavio: 40, validos: 200 },
],
])('fails closed on impossible counts: %s', (_label, row) => {
expect(computeSectionPotential(row)).toEqual({ available: false, reason: 'no-data' })
})
it('proves the gain is never negative under the approved hypothesis', () => {
let seed = 7
const random = (max: number) => {
seed = (seed * 1103515245 + 12345) % 2147483648
return seed % (max + 1)
}
for (let index = 0; index < 500; index += 1) {
const aptos = 50 + random(950)
const comparecimento = random(aptos)
const validos = random(comparecimento)
const lula = random(validos)
const flavio = random(validos - lula)
const result = computeSectionPotential({ aptos, comparecimento, lula, flavio, validos })
if (!result.available) continue
const { gainImmediatePp, gainTotalPp, firstRoundPct, immediatePct, totalPct } =
result.potential
expect(gainImmediatePp).toBeGreaterThanOrEqual(0)
expect(gainTotalPp).toBeGreaterThanOrEqual(gainImmediatePp)
expect(firstRoundPct).toBeGreaterThanOrEqual(0)
expect(totalPct).toBeLessThanOrEqual(100)
expect(immediatePct).toBeLessThanOrEqual(100)
}
})
})
describe('parsePotentialShard', () => {
const valid = {
version: 1,
uf: 'BA',
municipalities: [
{ code: 39098, name: 'Serrinha', sections: [[150, 50, 376, 289, 174, 76, 260]] },
],
}
it('accepts a well-formed shard', () => {
expect(parsePotentialShard(valid)).toEqual(valid)
})
it.each([
['a wrong version', { ...valid, version: 2 }],
['an unknown UF', { ...valid, uf: 'XX' }],
['a municipality without a name', { ...valid, municipalities: [{ code: 1, sections: [] }] }],
[
'a row with the wrong width',
{ ...valid, municipalities: [{ code: 1, name: 'X', sections: [[1, 2, 3]] }] },
],
[
'a row with a negative count',
{ ...valid, municipalities: [{ code: 1, name: 'X', sections: [[1, 2, -3, 4, 5, 6, 7]] }] },
],
['a non-object', null],
])('rejects %s', (_label, value) => {
expect(parsePotentialShard(value)).toBeNull()
})
})
describe('findPotentialSection', () => {
const shard: PotentialUfShard = {
version: 1,
uf: 'BA',
municipalities: [
{ code: 39098, name: 'Serrinha', sections: [[150, 50, 376, 289, 174, 76, 260]] },
{ code: 40000, name: 'Outro', sections: [[1, 1, 10, 8, 4, 2, 7]] },
],
}
it('finds a section through the binary searches', () => {
expect(
findPotentialSection(shard, { municipalityCode: 39098, zone: 150, section: 50 }),
).toEqual({
uf: 'BA',
municipalityCode: 39098,
municipalityName: 'Serrinha',
zone: 150,
section: 50,
aptos: 376,
comparecimento: 289,
lula: 174,
flavio: 76,
validos: 260,
})
})
it('answers null for an unknown município or section', () => {
expect(findPotentialSection(shard, { municipalityCode: 1, zone: 1, section: 1 })).toBeNull()
expect(findPotentialSection(shard, { municipalityCode: 39098, zone: 1, section: 1 })).toBeNull()
})
})
describe('formatters and helpers', () => {
it('formats percentages with one pt-BR decimal', () => {
expect(formatPotentialPercent(66.923)).toBe('66,9%')
expect(formatPotentialPercent(0)).toBe('0,0%')
})
it('derives the sign of the gain instead of hardcoding "+"', () => {
expect(formatPotentialPoints(6.864)).toBe('+6,9 p.p.')
expect(formatPotentialPoints(0)).toBe('+0,0 p.p.')
expect(formatPotentialPoints(-1.24)).toBe('−1,2 p.p.')
})
it('names the story file from the section identity', () => {
expect(sectionStoryFileName(numbers())).toBe('potencial-lula-ba-serrinha-z150-s50.png')
})
it('filters municípios accent-insensitively from three letters', () => {
const municipalities = [
{ code: 1, name: 'Serrinha' },
{ code: 2, name: 'São Gonçalo dos Campos' },
{ code: 3, name: 'Feira de Santana' },
]
expect(filterPotentialMunicipalities(municipalities, 'ser')).toEqual([
{ code: 1, name: 'Serrinha' },
])
expect(filterPotentialMunicipalities(municipalities, 'sao goncalo')).toEqual([
{ code: 2, name: 'São Gonçalo dos Campos' },
])
expect(filterPotentialMunicipalities(municipalities, 'se')).toEqual([])
})
})

View file

@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import {
drawSectionStory,
STORY_HEIGHT,
STORY_WIDTH,
type SectionStoryDrawContext,
type SectionStoryInput,
} from '@/lib/sectionStoryRender'
type FillCall = { text: string; x: number; y: number; font: string; fillStyle: string }
type Recorder = SectionStoryDrawContext & {
texts: FillCall[]
rects: { x: number; y: number; width: number; height: number; fillStyle: string }[]
strokes: { dash: number[]; strokeStyle: string }[]
lineDash: number[]
}
const createRecorder = () => {
const texts: FillCall[] = []
const rects: { x: number; y: number; width: number; height: number; fillStyle: string }[] = []
const strokes: { dash: number[]; strokeStyle: string }[] = []
const ctx: Recorder = {
font: '',
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
textAlign: 'left',
textBaseline: 'top',
letterSpacing: '',
fillRect(x, y, width, height) {
rects.push({ x, y, width, height, fillStyle: String(ctx.fillStyle) })
},
fillText(text, x, y) {
texts.push({ text, x, y, font: ctx.font, fillStyle: String(ctx.fillStyle) })
},
// Largura proporcional ao tamanho da fonte corrente — suficiente para o wrap.
measureText(text) {
const size = Number(/(\d+(?:\.\d+)?)px/.exec(ctx.font)?.[1] ?? '16')
return { width: text.length * size * 0.52 }
},
save() {},
restore() {},
beginPath() {},
moveTo() {},
lineTo() {},
stroke() {
strokes.push({ dash: ctx.lineDash, strokeStyle: String(ctx.strokeStyle) })
},
setLineDash(segments) {
ctx.lineDash = [...segments]
},
roundRect() {},
lineDash: [] as number[],
texts,
rects,
strokes,
}
return ctx
}
const input = (overrides: Partial<SectionStoryInput> = {}): SectionStoryInput => ({
municipalityName: 'Serrinha',
uf: 'BA',
zone: 150,
section: 50,
firstRound: '66,9%',
immediate: '73,7%',
total: '79,8%',
gainImmediate: '+6,8 p.p.',
gainTotal: '+12,9 p.p.',
fontFamily: 'Arimo, Arial, sans-serif',
...overrides,
})
describe('drawSectionStory', () => {
it('paints the 1080×1920 off-white background', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
expect(ctx.rects[0]).toEqual({
x: 0,
y: 0,
width: STORY_WIDTH,
height: STORY_HEIGHT,
fillStyle: '#f7f6f3',
})
})
it('draws the section identity, the three readings and both gains', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
const drawn = ctx.texts.map((call) => call.text)
const joined = drawn.join(' ')
expect(joined).toContain('2º TURNO · 25/10')
expect(joined).toContain('Quanto o Lula pode crescer na sua seção?')
expect(joined).toContain('Serrinha · BA')
expect(joined).toContain('Zona 150 · Seção 50')
expect(joined).toContain('66,9%')
expect(joined).toContain('73,7%')
expect(joined).toContain('79,8%')
expect(joined).toContain('+6,8 p.p.')
expect(joined).toContain('+12,9 p.p.')
expect(joined).toContain('X₁')
expect(joined).toContain('X₂')
})
it('draws the hero gain with the 700 48px face and keeps every text inside the canvas', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
const hero = ctx.texts.find((call) => call.text === '+12,9 p.p.')
expect(hero?.font).toContain('700 48px')
for (const call of ctx.texts) {
expect(call.y).toBeGreaterThanOrEqual(0)
expect(call.y).toBeLessThan(STORY_HEIGHT)
expect(call.x).toBeGreaterThanOrEqual(0)
expect(call.x).toBeLessThanOrEqual(STORY_WIDTH)
}
})
it('reserves the bottom link area with a dashed border', () => {
const ctx = createRecorder()
drawSectionStory(ctx, input())
expect(ctx.strokes.some((stroke) => stroke.dash.length === 2)).toBe(true)
})
it('clamps a long município name to two lines with an ellipsis', () => {
const ctx = createRecorder()
drawSectionStory(
ctx,
input({
municipalityName:
'São José do Vale do Rio Preto do Norte do Sertão do Cariri Paraibano da Serra do Mar e do Vale do Rio Doce de Minas Gerais do Oeste Baiano',
}),
)
expect(ctx.texts.some((call) => call.text.endsWith('…'))).toBe(true)
})
})

View file

@ -57,6 +57,7 @@ describe('shareLink slug', () => {
'jingles',
'mandato-no-whatsapp',
'noticia',
'potencial',
'privacidade',
].sort(),
)