mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
141 lines
5.6 KiB
JavaScript
141 lines
5.6 KiB
JavaScript
/**
|
|
* Boletim model (owner: C186; unit seam C187): one A4 page, voter-facing. It
|
|
* reads ONLY the `bulletinFacts` ledger produced by `buildDossierReport` — every
|
|
* fact there already carries a source, so the boletim cannot introduce a new
|
|
* fact. No sources are printed (they live in the dossiê). C209: the ledger also
|
|
* carries the `defense` facts ("O que Solla defende"), printed outside the
|
|
* highlight ranking and counted in the "e mais N" line.
|
|
*/
|
|
|
|
import { formatDateTimeBr } from './cityReportFormat.mjs'
|
|
import { dossierPhaseLabel } from './dossieBlocks.mjs'
|
|
import { BULLETIN_TIMELINE_STEPS } from './dossieCareer.mjs'
|
|
import { resolveDossierUnit } from './dossieUnit.mjs'
|
|
|
|
export const BULLETIN_HIGHLIGHT_LIMIT = 6
|
|
const BULLETIN_MORE_LIMIT = 14
|
|
/** "O que Solla defende" budget of the one-page boletim (design cena 12). */
|
|
export const BULLETIN_DEFENSE_LIMIT = 2
|
|
|
|
const eraRank = { C: 0, B: 1, A: 2 }
|
|
|
|
const rankFacts = (facts, unit) => {
|
|
const sphereRank = resolveDossierUnit(unit).breadthRank ?? {}
|
|
return [...facts].sort((left, right) => {
|
|
const sphere = (sphereRank[left.sphere] ?? 9) - (sphereRank[right.sphere] ?? 9)
|
|
if (sphere !== 0) return sphere
|
|
const hasValue = (left.value ? 0 : 1) - (right.value ? 0 : 1)
|
|
if (hasValue !== 0) return hasValue
|
|
const era = (eraRank[left.era] ?? 9) - (eraRank[right.era] ?? 9)
|
|
if (era !== 0) return era
|
|
return String(left.id).localeCompare(String(right.id))
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* facts?: Array<{ id: string, era: string, sphere: string, area: string, defense?: boolean, headline: string, detail: string|null, value: string|null, year: string|null, phase: string|null, sourceUrl: string, sourceDate: string, sourcePanel?: boolean }>,
|
|
* municipality?: string,
|
|
* region?: string | null,
|
|
* identity?: { name: string, badges?: string[] } | null,
|
|
* unit?: any,
|
|
* generatedAt?: Date,
|
|
* printLimit?: number | null,
|
|
* defenseLimit?: number,
|
|
* }} params
|
|
*/
|
|
export const buildBulletin = ({
|
|
facts = [],
|
|
municipality = null,
|
|
region = null,
|
|
identity = null,
|
|
unit,
|
|
generatedAt = new Date(),
|
|
printLimit = null,
|
|
defenseLimit = BULLETIN_DEFENSE_LIMIT,
|
|
}) => {
|
|
const resolvedUnit = resolveDossierUnit(unit)
|
|
const ordered = rankFacts(facts, resolvedUnit)
|
|
// The acervo sample is a source panel, not a finding (C190): it is counted in
|
|
// the one-pager but never takes a printed slot from a sourced finding. The
|
|
// defenses (C209) are their own block, outside the highlight ranking.
|
|
const printable = ordered.filter((fact) => !fact.sourcePanel && !fact.defense)
|
|
const defenseFacts = ordered.filter((fact) => fact.defense)
|
|
const target =
|
|
printLimit === null
|
|
? BULLETIN_HIGHLIGHT_LIMIT + (resolvedUnit.bulletinMoreLimit ?? BULLETIN_MORE_LIMIT)
|
|
: Math.max(0, printLimit)
|
|
const printed = printable.slice(0, target)
|
|
const subjectName = municipality ?? identity?.name ?? '—'
|
|
const highlights = printed.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => {
|
|
// Theme-only fallback: a fact without money value shows its year; without
|
|
// either, the line prints no number at all (never a fake zero).
|
|
const number = resolvedUnit.bulletinNumberFallback
|
|
? (fact.value ?? fact.year ?? null)
|
|
: fact.value
|
|
return {
|
|
number,
|
|
title: fact.brief?.title ?? fact.headline,
|
|
note: fact.brief?.note ?? fact.detail ?? null,
|
|
phaseLabel: fact.value ? dossierPhaseLabel(fact.phase) : null,
|
|
}
|
|
})
|
|
const moreItems = printed.slice(BULLETIN_HIGHLIGHT_LIMIT).map((fact) => ({
|
|
label: fact.brief?.title ?? fact.headline,
|
|
detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area,
|
|
}))
|
|
const defenses = defenseFacts.slice(0, Math.max(0, defenseLimit)).map((fact) => ({
|
|
label: fact.brief?.title ?? fact.headline,
|
|
text: fact.brief?.note ?? fact.detail ?? fact.headline,
|
|
}))
|
|
const shown = highlights.length + moreItems.length + defenses.length
|
|
|
|
return {
|
|
unit: resolvedUnit,
|
|
sparse: printable.length < 3,
|
|
meta: {
|
|
title: resolvedUnit.bulletinTitle,
|
|
kicker:
|
|
resolvedUnit.bulletinKicker ??
|
|
(municipality
|
|
? `Boletim informativo · ${subjectName}`
|
|
: 'Boletim informativo · atuação pública'),
|
|
subjectName,
|
|
municipality: subjectName,
|
|
region,
|
|
identity,
|
|
identityBadges: identity?.badges ?? [],
|
|
generatedAt,
|
|
generatedAtLabel: formatDateTimeBr(generatedAt),
|
|
modelLabel: 'Modelo — insumo interno',
|
|
},
|
|
highlights,
|
|
defenses,
|
|
moreItems,
|
|
// Never a silent drop (C188): the facts beyond the caps — and the whole
|
|
// source panel — are counted so the one-page boletim states how much of the
|
|
// sourced set it left out.
|
|
factsTotal: ordered.length,
|
|
factsPrintable: printable.length + defenseFacts.length,
|
|
factsPrinted: shown,
|
|
factsRemaining: ordered.length - shown,
|
|
timeline: BULLETIN_TIMELINE_STEPS,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One shrink step of the one-page fit (C190/C209): reduce the printed highlights
|
|
* first (two at a time), then the defenses (2 → 1 → 0). Returns the options for
|
|
* the next `buildBulletin`, or null when there is nothing left to cut — the
|
|
* builder then fails closed instead of truncating.
|
|
*
|
|
* @param {any} bulletin
|
|
* @returns {{ printLimit?: number, defenseLimit?: number } | null}
|
|
*/
|
|
export const nextBulletinFit = (bulletin) => {
|
|
const printedFacts = bulletin.highlights.length + bulletin.moreItems.length
|
|
if (printedFacts > 0) return { printLimit: Math.max(0, printedFacts - 2) }
|
|
if (bulletin.defenses.length > 1) return { printLimit: 0, defenseLimit: 1 }
|
|
if (bulletin.defenses.length > 0) return { printLimit: 0, defenseLimit: 0 }
|
|
return null
|
|
}
|