mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(C186): dossiê/boletim sem cortes — copy reformulada (brief) + paginação (Related #1132)
O builder cortava o texto no meio com "…" quando não cabia no A4, perdendo informação. Agora: - contrato de pesquisa ganha `brief` (`title` ≤80 / `note` ≤120) reescrito pelo researcher: o PDF imprime o brief reformulado e o registro/.md mantém `answer`/`details` integrais (sem fonte nova, sem trocar a fase); - sem `brief` o renderer cai no texto completo e a guarda de fit A4 falha fechado — nunca mais corta em silêncio; - tabelas longas (lacunas, notícias) e os cards de "Atuação registrada" das eras são paginados em folhas de continuação: nada é truncado nem descartado; - corrige honrarias da Era C vazando para a página da Era B; - remove o `clipCopy`/`MAX_NEWS_ROWS`; SKILL.md e agent doc documentam o `brief`.
This commit is contained in:
parent
ef87c68a18
commit
f658d5eaaa
9 changed files with 341 additions and 75 deletions
|
|
@ -150,8 +150,12 @@ cruza para ele.
|
|||
"items": [
|
||||
{
|
||||
"id": "era_c_emendas", // id do checklist da era
|
||||
"answer": "R$ 1,2 milhão para equipamentos de saúde",
|
||||
"answer": "registro integral; aceita {{fonte}} / {{fonte:N}} — vai para o .md e para o lastro",
|
||||
"details": "opcional; aceita {{fonte}} / {{fonte:N}}",
|
||||
"brief": { // obrigatório no item publicado: copy reformulada, curta
|
||||
"title": "≤80 chars — manchete (o quê + onde)",
|
||||
"note": "≤120 chars — 1 frase de contexto; opcional"
|
||||
},
|
||||
"sphere": "municipio", // municipio | regiao | polo (default municipio)
|
||||
"numbers": [ // opcional
|
||||
{ "label": "Saúde", "value": "R$ 1,2 mi", "year": "2024", "phase": "empenhado" }
|
||||
|
|
@ -183,6 +187,17 @@ Item sem `sourceUrl`/`sourceDate`, item ausente ou item de outra era vira
|
|||
(default `nao_informado`). O `bulletinFacts` (ledger do boletim) só é populado
|
||||
por item **com fonte** — o boletim não introduz fato novo.
|
||||
|
||||
**`brief` (copy reformulada, sem reticências).** As páginas A4 têm altura fixa:
|
||||
em vez de cortar o texto com `…`, cada item publicado traz um `brief` **reescrito
|
||||
para caber** (`title` ≤80 / `note` ≤120) preservando o essencial (o quê, onde,
|
||||
valor + fase) — nunca inventa fato, nunca sugere exclusividade municipal para
|
||||
item `regiao`/`polo` e nunca troca a fase (empenho ≠ pagamento). O cartão/linha
|
||||
do PDF imprime `brief.title`/`brief.note`; o `answer`/`details` integrais ficam
|
||||
no registro e no companion `.md`. Sem `brief`, o renderer cai no texto integral e
|
||||
a guarda de fit A4 do builder **falha fechado** (não corta em silêncio). Tabelas
|
||||
longas (lacunas, notícias) não usam `brief`: são **paginadas** em folhas de
|
||||
continuação com o texto inteiro.
|
||||
|
||||
## Conteúdo do dossiê
|
||||
|
||||
- **Capa** — série municipal, "INSUMO INTERNO", território, região/polo
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ slug e coordena as demais eras/cidades em paralelo.
|
|||
(ex.: `ilheus.c.research.json`) — item sem `sourceUrl`/`sourceDate` não vai
|
||||
para o arquivo (vira lacuna explícita). Use o checklist da era e marque
|
||||
`sphere` (`municipio`/`regiao`/`polo`); região/polo nunca é somado ao
|
||||
município.
|
||||
município. Cada item publicado traz **`brief`** (`title` ≤80 / `note` ≤120):
|
||||
copy **reformulada** para caber no A4, sem `…`, sem fato novo e sem trocar a
|
||||
fase — o `answer`/`details` integrais continuam no arquivo (registro/.md).
|
||||
3. Devolver **apenas o recibo curto** (seção "Recibo do researcher" da skill):
|
||||
`slug`, `era`, `status`, `researchPath`, `researchedAt`, `itemCount`,
|
||||
`gapCount`, `newsCount`, `gaps`, `failureReason?`. **Nunca** devolva o corpo
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@ import { stripInlineSources } from './reportText.mjs'
|
|||
const MAX_DELIVERIES_PAGE_ONE = 3
|
||||
const MAX_HOOKS_PAGE_ONE = 2
|
||||
const MAX_PENDING_PAGE_ONE = 4
|
||||
const MAX_ERA_NUMBERS = 12
|
||||
const MAX_ERA_ACTIONS = 4
|
||||
const MAX_REGION_ITEMS = 4
|
||||
const MAX_SCOPE_LIST = 5
|
||||
const MAX_ERA_NUMBERS = 6
|
||||
/** Curated research actions are all kept (the renderer paginates them); only the generic Câmara list is capped. */
|
||||
const MAX_ERA_CAMARA_ACTIONS = 4
|
||||
const MAX_REGION_ITEMS = 3
|
||||
const MAX_SCOPE_LIST = 3
|
||||
|
||||
const sphereLabels = { municipio: 'município', regiao: 'região', polo: 'polo' }
|
||||
export const dossierSphereLabel = (sphere) => sphereLabels[sphere] ?? sphere
|
||||
|
|
@ -171,6 +172,7 @@ export const buildDossierReport = ({
|
|||
year: item.numbers?.[0]?.year ?? null,
|
||||
title: item.answer,
|
||||
detail: item.details,
|
||||
brief: item.brief ?? null,
|
||||
sourceUrl: item.sourceUrl,
|
||||
sourceDate: item.sourceDate,
|
||||
}))
|
||||
|
|
@ -184,10 +186,17 @@ export const buildDossierReport = ({
|
|||
: itemNumberRows(items.filter((i) => i.era === era.id)),
|
||||
MAX_ERA_NUMBERS,
|
||||
)
|
||||
const honors = items
|
||||
.filter((item) => item.era === 'C' && item.id === 'era_c_titulos')
|
||||
.map((item) => ({ text: item.answer, sourceUrl: item.sourceUrl }))
|
||||
const actions = limit([...ownActions, ...extraActions], MAX_ERA_ACTIONS)
|
||||
const honors =
|
||||
era.id === 'C'
|
||||
? items
|
||||
.filter((item) => item.id === 'era_c_titulos')
|
||||
.map((item) => ({
|
||||
text: item.answer,
|
||||
brief: item.brief ?? null,
|
||||
sourceUrl: item.sourceUrl,
|
||||
}))
|
||||
: []
|
||||
const actions = [...ownActions, ...limit(extraActions, MAX_ERA_CAMARA_ACTIONS)]
|
||||
if (numbers.length === 0 && actions.length === 0) return null
|
||||
return {
|
||||
...era,
|
||||
|
|
@ -215,6 +224,7 @@ export const buildDossierReport = ({
|
|||
year: item.numbers?.[0]?.year ?? null,
|
||||
title: item.answer,
|
||||
detail: item.details,
|
||||
brief: item.brief ?? null,
|
||||
value: item.numbers?.[0]?.value ?? null,
|
||||
phase: item.numbers?.[0]?.phase ?? null,
|
||||
sourceUrl: item.sourceUrl,
|
||||
|
|
@ -227,6 +237,7 @@ export const buildDossierReport = ({
|
|||
[...municipalItems, ...regionalItems].map((item) => ({
|
||||
topic: item.area,
|
||||
angle: item.answer,
|
||||
brief: item.brief ?? null,
|
||||
sourceUrl: item.sourceUrl,
|
||||
})),
|
||||
MAX_HOOKS_PAGE_ONE,
|
||||
|
|
@ -249,6 +260,7 @@ export const buildDossierReport = ({
|
|||
item: item.answer,
|
||||
sphere: item.sphere,
|
||||
evidence: item.details ?? item.label,
|
||||
brief: item.brief ?? null,
|
||||
sourceUrl: item.sourceUrl,
|
||||
})),
|
||||
MAX_REGION_ITEMS,
|
||||
|
|
@ -264,6 +276,12 @@ export const buildDossierReport = ({
|
|||
area: item.area,
|
||||
headline: stripInlineSources(item.answer),
|
||||
detail: item.details ? stripInlineSources(item.details) : null,
|
||||
brief: item.brief
|
||||
? {
|
||||
title: stripInlineSources(item.brief.title),
|
||||
note: item.brief.note ? stripInlineSources(item.brief.note) : null,
|
||||
}
|
||||
: null,
|
||||
value: item.numbers?.[0]?.value ?? null,
|
||||
numberLabel: item.numbers?.[0]?.label ?? null,
|
||||
year: item.numbers?.[0]?.year ?? null,
|
||||
|
|
|
|||
|
|
@ -44,14 +44,14 @@ export const buildBulletin = ({
|
|||
const highlights = ordered.slice(0, BULLETIN_HIGHLIGHT_LIMIT).map((fact) => ({
|
||||
eyebrow: `${fact.area ?? 'Atuação'} · ${dossierSphereLabel(fact.sphere)}`,
|
||||
number: fact.value,
|
||||
title: fact.headline,
|
||||
note: fact.detail ?? null,
|
||||
title: fact.brief?.title ?? fact.headline,
|
||||
note: fact.brief?.note ?? fact.detail,
|
||||
}))
|
||||
const moreItems = ordered
|
||||
.slice(BULLETIN_HIGHLIGHT_LIMIT, BULLETIN_HIGHLIGHT_LIMIT + BULLETIN_MORE_LIMIT)
|
||||
.map((fact) => ({
|
||||
label: fact.headline,
|
||||
detail: fact.detail ?? fact.value ?? fact.area,
|
||||
label: fact.brief?.title ?? fact.headline,
|
||||
detail: fact.brief?.note ?? fact.detail ?? fact.value ?? fact.area,
|
||||
}))
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const renderHighlight = (highlight) => `
|
|||
</article>`
|
||||
|
||||
const renderMoreItem = (item) =>
|
||||
`<p class="more-item"><strong>${htmlEscape(item.label)}:</strong> ${htmlEscape(item.detail)}</p>`
|
||||
`<p class="more-item"><strong>${htmlEscape(item.label)}</strong></p>`
|
||||
|
||||
const PRINT_CSS = `
|
||||
* { box-sizing: border-box; }
|
||||
|
|
|
|||
|
|
@ -30,8 +30,15 @@ const formatShortDateBr = (value) => {
|
|||
return parts.length === 3 ? `${parts[0]}/${parts[1]}/${parts[2].slice(-2)}` : full
|
||||
}
|
||||
|
||||
/** Research text without the inline `{{fonte}}` markers — the row's own link carries the source. */
|
||||
const cleanText = (value) => htmlEscape(stripInlineSources(value))
|
||||
/**
|
||||
* Print surfaces prefer the reformulated `brief` (short headline + note that fit
|
||||
* the fixed A4 with no ellipsis) and fall back to the full `answer`/`details`
|
||||
* record when an item has none — the builder's A4 fit guard then fails closed
|
||||
* instead of silently dropping information.
|
||||
*/
|
||||
const copyHtml = (value) => htmlEscape(stripInlineSources(value))
|
||||
const briefOr = (brief, fallback) => brief?.title ?? fallback
|
||||
const noteOr = (brief, fallback) => brief?.note ?? fallback
|
||||
|
||||
const phaseBadge = (phase) => `<span class="phase">${htmlEscape(dossierPhaseLabel(phase))}</span>`
|
||||
|
||||
|
|
@ -52,8 +59,15 @@ const assetBox = (title, lines) =>
|
|||
const SHEET_COUNT_BASE = 5
|
||||
const pageCount = (report) => report.meta.pageTotal
|
||||
|
||||
/** Gaps are paginated, never truncated: 12 rows per A4 sheet. */
|
||||
const GAPS_PER_PAGE = 12
|
||||
/** Gaps are paginated, never truncated: 7 full-text rows per A4 sheet. */
|
||||
const GAPS_PER_PAGE = 7
|
||||
|
||||
/** News are paginated, never truncated: 10 full-title rows per A4 sheet. */
|
||||
const NEWS_PER_PAGE = 10
|
||||
|
||||
/** Era "Atuação registrada" cards: 2 on the first sheet, 6 per continuation sheet. */
|
||||
const ERA_ACTIONS_FIRST_SHEET = 2
|
||||
const ERA_ACTIONS_PER_SHEET = 6
|
||||
|
||||
const chunkGaps = (gaps) => {
|
||||
const list = Array.isArray(gaps) ? gaps : []
|
||||
|
|
@ -65,6 +79,31 @@ const chunkGaps = (gaps) => {
|
|||
return chunks
|
||||
}
|
||||
|
||||
const chunkNews = (news) => {
|
||||
const list = Array.isArray(news) ? news : []
|
||||
const chunks = []
|
||||
for (let index = 0; index < list.length; index += NEWS_PER_PAGE) {
|
||||
chunks.push(list.slice(index, index + NEWS_PER_PAGE))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Era action cards, paginated so no sourced fact is dropped: the first sheet
|
||||
* carries the design's 2 cards (with method + numbers + honors), and the rest
|
||||
* flow to continuation sheets.
|
||||
*/
|
||||
const chunkEraActions = (era) => {
|
||||
const actions = Array.isArray(era.actions) ? era.actions : []
|
||||
const first = actions.slice(0, ERA_ACTIONS_FIRST_SHEET)
|
||||
const rest = actions.slice(ERA_ACTIONS_FIRST_SHEET)
|
||||
const chunks = [first]
|
||||
for (let index = 0; index < rest.length; index += ERA_ACTIONS_PER_SHEET) {
|
||||
chunks.push(rest.slice(index, index + ERA_ACTIONS_PER_SHEET))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
const timelineGrid = (rows) => `
|
||||
<div class="timeline-grid">
|
||||
${rows
|
||||
|
|
@ -165,8 +204,8 @@ const renderDeliveries = (report) => {
|
|||
(row) => `<div class="delivery-row">
|
||||
<div class="delivery-scope">${scopeBadge(row.sphere)}<p class="meta">Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}</p></div>
|
||||
<div>
|
||||
<p class="delivery-title">${cleanText(row.title)}</p>
|
||||
${row.detail ? `<p class="muted small">${cleanText(row.detail)}</p>` : ''}
|
||||
<p class="delivery-title">${copyHtml(briefOr(row.brief, row.title))}</p>
|
||||
${noteOr(row.brief, row.detail) ? `<p class="muted small">${copyHtml(noteOr(row.brief, row.detail))}</p>` : ''}
|
||||
</div>
|
||||
<div class="delivery-value">
|
||||
<p class="value-big tabular">${valueCell(row.value, 'Valor não informado')}</p>
|
||||
|
|
@ -194,7 +233,7 @@ const renderHooksAndPending = (report) => {
|
|||
<ul class="tight-list">${hooks
|
||||
.map(
|
||||
(hook) =>
|
||||
`<li><strong>Tema:</strong> ${htmlEscape(hook.topic)}. <strong>Ângulo:</strong> ${cleanText(hook.angle)} ${sourceLink(hook.sourceUrl)}</li>`,
|
||||
`<li><strong>Tema:</strong> ${htmlEscape(hook.topic)}. <strong>Ângulo:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}</li>`,
|
||||
)
|
||||
.join('')}</ul>
|
||||
</div>`
|
||||
|
|
@ -221,11 +260,34 @@ const renderSummary = (report, pageNo) => `
|
|||
${sheetFooter(report, 'Resumo', pageNo)}
|
||||
</article>`
|
||||
|
||||
const renderEra = (report, era, pageNo) => `
|
||||
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
|
||||
${sheetHeader(report, era.label, `${era.period} · ${era.subtitle}`, pageNo)}
|
||||
const renderActionGrid = (actions) => `
|
||||
<div class="action-grid">
|
||||
${actions
|
||||
.map(
|
||||
(action) => `<article class="panel">
|
||||
<div class="action-head">${scopeBadge(action.sphere)}<span class="meta">${htmlEscape(action.year ?? '—')}</span></div>
|
||||
<h4 class="action-title">${copyHtml(briefOr(action.brief, action.title))}</h4>
|
||||
${noteOr(action.brief, action.detail) ? `<p class="muted small">${copyHtml(noteOr(action.brief, action.detail))}</p>` : ''}
|
||||
${sourceLink(action.sourceUrl)}
|
||||
</article>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>`
|
||||
|
||||
<div class="era-method">
|
||||
const renderEraPage = (report, era, actions, index, pageNo) => {
|
||||
const first = index === 0
|
||||
const slug = era.id.toLowerCase()
|
||||
const anchor = first ? `era-${slug}` : `era-${slug}-${index + 1}`
|
||||
const subtitle = first
|
||||
? `${era.period} · ${era.subtitle}`
|
||||
: `Atuação registrada · continuação ${index + 1}`
|
||||
return `
|
||||
<article class="sheet" data-page="${anchor}" aria-label="${htmlEscape(era.label)}">
|
||||
${sheetHeader(report, era.label, subtitle, pageNo)}
|
||||
|
||||
${
|
||||
first
|
||||
? `<div class="era-method">
|
||||
<div>
|
||||
<p class="eyebrow">Recorte e método</p>
|
||||
<p class="muted">${htmlEscape(era.method)}</p>
|
||||
|
|
@ -256,39 +318,32 @@ const renderEra = (report, era, pageNo) => `
|
|||
)
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
<p class="warning-text small strong">Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.</p>
|
||||
</section>`
|
||||
: ''
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
era.actions.length
|
||||
actions.length
|
||||
? `<section class="block">
|
||||
<p class="eyebrow">Atuação registrada</p>
|
||||
<h3 class="section-title">O que fez — item, alcance e lastro</h3>
|
||||
<div class="action-grid">
|
||||
${era.actions
|
||||
.map(
|
||||
(action) => `<article class="panel">
|
||||
<div class="action-head">${scopeBadge(action.sphere)}<span class="meta">${htmlEscape(action.year ?? '—')}</span></div>
|
||||
<h4 class="action-title">${cleanText(action.title)}</h4>
|
||||
${action.detail ? `<p class="muted small">${cleanText(action.detail)}</p>` : ''}
|
||||
${sourceLink(action.sourceUrl)}
|
||||
</article>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
${renderActionGrid(actions)}
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
${
|
||||
era.honors.length
|
||||
first && era.honors.length
|
||||
? `<section class="block honors">
|
||||
<div class="panel">
|
||||
<p class="eyebrow">Títulos, honrarias e vínculos locais</p>
|
||||
<ul class="tight-list">${era.honors
|
||||
.map((honor) => `<li>${cleanText(honor.text)} ${sourceLink(honor.sourceUrl)}</li>`)
|
||||
.map(
|
||||
(honor) =>
|
||||
`<li>${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}</li>`,
|
||||
)
|
||||
.join('')}</ul>
|
||||
</div>
|
||||
${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])}
|
||||
|
|
@ -298,6 +353,7 @@ const renderEra = (report, era, pageNo) => `
|
|||
|
||||
${sheetFooter(report, era.label, pageNo)}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const renderScopeLists = (report) => `
|
||||
<section class="block">
|
||||
|
|
@ -307,7 +363,10 @@ const renderScopeLists = (report) => `
|
|||
<div class="panel">
|
||||
<div class="action-head">${scopeBadge('municipio')}<span class="meta strong">${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte</span></div>
|
||||
<ul class="tight-list">${report.region.municipal.items
|
||||
.map((item) => `<li>${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}</li>`)
|
||||
.map(
|
||||
(item) =>
|
||||
`<li>${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}</li>`,
|
||||
)
|
||||
.join(
|
||||
'',
|
||||
)}${report.region.municipal.items.length === 0 ? '<li class="muted">Nenhum item municipal com fonte.</li>' : ''}</ul>
|
||||
|
|
@ -316,7 +375,10 @@ const renderScopeLists = (report) => `
|
|||
<div class="panel panel-region">
|
||||
<div class="action-head">${scopeBadge('regiao', 'região / polo')}<span class="warning-text meta strong">SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}</span></div>
|
||||
<ul class="tight-list">${report.region.regional.items
|
||||
.map((item) => `<li>${cleanText(item.answer)} ${sourceLink(item.sourceUrl)}</li>`)
|
||||
.map(
|
||||
(item) =>
|
||||
`<li>${copyHtml(item.brief?.title ?? item.answer)} ${sourceLink(item.sourceUrl)}</li>`,
|
||||
)
|
||||
.join(
|
||||
'',
|
||||
)}${report.region.regional.items.length === 0 ? '<li class="muted">Nenhum item regional com fonte.</li>' : ''}</ul>
|
||||
|
|
@ -340,7 +402,7 @@ const renderRegion = (report, pageNo) => `
|
|||
report.region.context.length
|
||||
? `<p class="meta">Contexto municipal (IBGE): ${report.region.context
|
||||
.map(
|
||||
(item) => `${htmlEscape(item.detail)} ${sourceLink(item.sourceUrl, `(${item.topic})`)}`,
|
||||
(item) => `${copyHtml(item.detail)} ${sourceLink(item.sourceUrl, `(${item.topic})`)}`,
|
||||
)
|
||||
.join(' · ')} — leitura relativa, nunca % estadual absoluto.</p>`
|
||||
: ''
|
||||
|
|
@ -359,9 +421,9 @@ const renderRegion = (report, pageNo) => `
|
|||
<tbody>${report.region.items
|
||||
.map(
|
||||
(item) => `<tr>
|
||||
<td>${cleanText(item.item)}</td>
|
||||
<td>${copyHtml(briefOr(item.brief, item.item))}</td>
|
||||
<td>${scopeBadge(item.sphere)}</td>
|
||||
<td>${cleanText(item.evidence)}</td>
|
||||
<td>${copyHtml(noteOr(item.brief, item.evidence))}</td>
|
||||
<td>${sourceLink(item.sourceUrl)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
|
|
@ -376,7 +438,7 @@ const renderRegion = (report, pageNo) => `
|
|||
? `<section class="page1-grid">
|
||||
${
|
||||
report.region.hook
|
||||
? `<div class="aside-accent"><p class="eyebrow">Gancho possível</p><p><strong>Tema:</strong> ${htmlEscape(report.region.hook.topic)}. <strong>Ângulo:</strong> ${cleanText(report.region.hook.angle)} ${sourceLink(report.region.hook.sourceUrl)}</p></div>`
|
||||
? `<div class="aside-accent"><p class="eyebrow">Gancho possível</p><p><strong>Tema:</strong> ${htmlEscape(report.region.hook.topic)}. <strong>Ângulo:</strong> ${copyHtml(briefOr(report.region.hook.brief, report.region.hook.angle))} ${sourceLink(report.region.hook.sourceUrl)}</p></div>`
|
||||
: '<div></div>'
|
||||
}
|
||||
${
|
||||
|
|
@ -420,34 +482,35 @@ const renderGapsPage = (report, chunk, index, pageNo) => {
|
|||
</article>`
|
||||
}
|
||||
|
||||
const renderNewsPage = (report, chunk, index, pageNo) => `
|
||||
<article class="sheet" data-page="noticias${index > 0 ? `-${index + 1}` : ''}" aria-label="Notícias e documentos consultados">
|
||||
${sheetHeader(report, 'Notícias e documentos consultados', `Fontes externas datadas${index > 0 ? ` (continuação ${index + 1})` : ''}`, pageNo)}
|
||||
<section class="block">
|
||||
<p class="eyebrow">Lista clicável</p>
|
||||
<h3 class="section-title">Notícias e documentos consultados</h3>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%</caption>
|
||||
<colgroup><col style="width:12%" /><col style="width:13%" /><col style="width:47%" /><col style="width:28%" /></colgroup>
|
||||
<thead><tr><th>Data</th><th>Veículo</th><th>Título</th><th>Link</th></tr></thead>
|
||||
<tbody>${chunk
|
||||
.map(
|
||||
(row) => `<tr>
|
||||
<td class="numeric nowrap">${htmlEscape(formatShortDateBr(row.date))}</td>
|
||||
<td>${htmlEscape(row.outlet)}</td>
|
||||
<td>${htmlEscape(row.title)}</td>
|
||||
<td>${sourceLink(row.url, '(fonte)')}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
${sheetFooter(report, 'Notícias e documentos consultados · INSUMO INTERNO', pageNo)}
|
||||
</article>`
|
||||
|
||||
const renderSourcesPage = (report, pageNo) => `
|
||||
<article class="sheet" data-page="fontes" aria-label="Fontes e limites do dossiê">
|
||||
${sheetHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
|
||||
|
||||
${
|
||||
report.news.length
|
||||
? `<section class="block">
|
||||
<p class="eyebrow">Lista clicável</p>
|
||||
<h3 class="section-title">Notícias e documentos consultados</h3>
|
||||
<table class="document-table">
|
||||
<caption class="sr-only">Fontes consultadas com largura fixa: data 12%, veículo 13%, título 47% e link 28%</caption>
|
||||
<colgroup><col style="width:12%" /><col style="width:13%" /><col style="width:47%" /><col style="width:28%" /></colgroup>
|
||||
<thead><tr><th>Data</th><th>Veículo</th><th>Título</th><th>Link</th></tr></thead>
|
||||
<tbody>${report.news
|
||||
.map(
|
||||
(row) => `<tr>
|
||||
<td class="numeric nowrap">${htmlEscape(formatShortDateBr(row.date))}</td>
|
||||
<td>${htmlEscape(row.outlet)}</td>
|
||||
<td>${htmlEscape(row.title)}</td>
|
||||
<td>${sourceLink(row.url, '(fonte)')}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('')}</tbody>
|
||||
</table>
|
||||
</section>`
|
||||
: ''
|
||||
}
|
||||
|
||||
<section class="page1-grid">
|
||||
<div class="panel"><p class="eyebrow">Limites de cobertura</p><ul class="tight-list">${report.limits.coverage
|
||||
.map((item) => `<li>${htmlEscape(item)}</li>`)
|
||||
|
|
@ -578,16 +641,24 @@ const PRINT_CSS = `
|
|||
|
||||
export const renderDossierHtml = (report) => {
|
||||
const gapChunks = chunkGaps(report.gaps)
|
||||
report.meta.pageTotal = SHEET_COUNT_BASE + report.eras.length + gapChunks.length
|
||||
const newsChunks = chunkNews(report.news)
|
||||
const eraSheets = report.eras.map((era) => ({ era, chunks: chunkEraActions(era) }))
|
||||
const eraSheetCount = eraSheets.reduce((sum, entry) => sum + entry.chunks.length, 0)
|
||||
report.meta.pageTotal = SHEET_COUNT_BASE + eraSheetCount + gapChunks.length + newsChunks.length
|
||||
let page = 0
|
||||
const parts = []
|
||||
parts.push(renderCover(report))
|
||||
page += 1
|
||||
parts.push(renderTrajectory(report, ++page))
|
||||
parts.push(renderSummary(report, ++page))
|
||||
for (const era of report.eras) parts.push(renderEra(report, era, ++page))
|
||||
for (const { era, chunks } of eraSheets) {
|
||||
chunks.forEach((actions, index) =>
|
||||
parts.push(renderEraPage(report, era, actions, index, ++page)),
|
||||
)
|
||||
}
|
||||
parts.push(renderRegion(report, ++page))
|
||||
gapChunks.forEach((chunk, index) => parts.push(renderGapsPage(report, chunk, index, ++page)))
|
||||
newsChunks.forEach((chunk, index) => parts.push(renderNewsPage(report, chunk, index, ++page)))
|
||||
parts.push(renderSourcesPage(report, ++page))
|
||||
|
||||
return `<!doctype html>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ const DOSSIER_SPHERES = ['municipio', 'regiao', 'polo']
|
|||
/** Execution phases (empenho ≠ pagamento). Unknown/absent degrades to "não informada". */
|
||||
const DOSSIER_PHASES = ['autorizado', 'empenhado', 'liquidado', 'pago', 'restos', 'nao_informado']
|
||||
|
||||
/**
|
||||
* Copy budget for a printed `brief` (reformulated headline + note that must fit
|
||||
* the fixed A4 surfaces with no ellipsis). The full `answer`/`details` stay in
|
||||
* the record and the companion `.md`; the `brief` is the reformulated, shorter
|
||||
* version the PDF prints. Items without a `brief` fall back to the full text and
|
||||
* the builder's A4 fit guard fails closed if it overflows.
|
||||
*/
|
||||
export const DOSSIER_BRIEF_TITLE_MAX = 80
|
||||
export const DOSSIER_BRIEF_NOTE_MAX = 120
|
||||
|
||||
/** Checklist per era — the dossiê's research checklist. `area` labels the boletim card. */
|
||||
const DOSSIER_RESEARCH_CHECKLIST = [
|
||||
{
|
||||
|
|
@ -116,6 +126,27 @@ const normalizePhase = (value) => {
|
|||
return DOSSIER_PHASES.includes(phase) ? phase : 'nao_informado'
|
||||
}
|
||||
|
||||
const normalizeBrief = (entry) => {
|
||||
const title = isNonEmptyString(entry?.title) ? entry.title.trim() : null
|
||||
if (!title) return null
|
||||
return { title, note: isNonEmptyString(entry?.note) ? entry.note.trim() : null }
|
||||
}
|
||||
|
||||
/** Non-fatal: an over-budget brief is kept, but the A4 fit guard will likely fail — surface it early. */
|
||||
const warnIfBriefOverBudget = (id, brief) => {
|
||||
if (!brief) return
|
||||
if (brief.title.length > DOSSIER_BRIEF_TITLE_MAX) {
|
||||
console.warn(
|
||||
`[dossie-research] brief.title de ${id} tem ${brief.title.length} chars (teto ${DOSSIER_BRIEF_TITLE_MAX}).`,
|
||||
)
|
||||
}
|
||||
if ((brief.note?.length ?? 0) > DOSSIER_BRIEF_NOTE_MAX) {
|
||||
console.warn(
|
||||
`[dossie-research] brief.note de ${id} tem ${brief.note.length} chars (teto ${DOSSIER_BRIEF_NOTE_MAX}).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeNumbers = (entry) => {
|
||||
const numbers = []
|
||||
for (const number of Array.isArray(entry?.numbers) ? entry.numbers : []) {
|
||||
|
|
@ -208,6 +239,8 @@ export const normalizeDossierResearchInput = (raw) => {
|
|||
})
|
||||
continue
|
||||
}
|
||||
const brief = normalizeBrief(entry.brief)
|
||||
warnIfBriefOverBudget(id, brief)
|
||||
items.push({
|
||||
id,
|
||||
era,
|
||||
|
|
@ -215,6 +248,7 @@ export const normalizeDossierResearchInput = (raw) => {
|
|||
area: checklistItem.area,
|
||||
answer,
|
||||
details: isNonEmptyString(entry.details) ? entry.details.trim() : null,
|
||||
brief,
|
||||
sphere,
|
||||
numbers: normalizeNumbers(entry),
|
||||
sourceUrl,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,12 @@ const research = mergeDossierResearch([
|
|||
sourceUrl: 'https://portaldatransparencia.test/emendas',
|
||||
sourceDate: '2026-09-15',
|
||||
},
|
||||
{
|
||||
id: 'era_c_titulos',
|
||||
answer: 'Título de cidadão honorário',
|
||||
sourceUrl: 'https://camara.test/titulo',
|
||||
sourceDate: '2026-09-10',
|
||||
},
|
||||
],
|
||||
news: [
|
||||
{
|
||||
|
|
@ -120,6 +126,89 @@ describe('renderDossierHtml', () => {
|
|||
it('prints the defeso note', () => {
|
||||
expect(html).toContain('Nota de defeso eleitoral 2026')
|
||||
})
|
||||
|
||||
it('renders the local honors only on their own era sheet (never on another era)', () => {
|
||||
expect((html.match(/Títulos, honrarias e vínculos locais/g) ?? []).length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderDossierHtml — reformulated brief (no ellipsis)', () => {
|
||||
const briefedResearch = mergeDossierResearch([
|
||||
normalizeDossierResearchInput({
|
||||
municipalitySlug: 'ilheus',
|
||||
era: 'C',
|
||||
researchedAt: '2026-09-16T10:00:00.000Z',
|
||||
items: [
|
||||
{
|
||||
id: 'era_c_emendas',
|
||||
answer: 'Resposta integral bem longa que não deve aparecer no cartão impresso do dossiê',
|
||||
details: 'Detalhe integral que também não deve aparecer no cartão impresso.',
|
||||
brief: { title: 'Manchete reformulada para caber', note: 'Nota reformulada curta.' },
|
||||
numbers: [{ label: 'Saúde', value: 'R$ 1,2 mi', year: '2024', phase: 'empenhado' }],
|
||||
sourceUrl: 'https://portaldatransparencia.test/brief',
|
||||
sourceDate: '2026-09-15',
|
||||
},
|
||||
],
|
||||
news: Array.from({ length: 12 }, (_value, index) => ({
|
||||
title: `Notícia ${index}`,
|
||||
url: `https://jornal.test/${index}`,
|
||||
publishedAt: '2024-06-01',
|
||||
outlet: 'Jornal Local',
|
||||
})),
|
||||
gaps: [],
|
||||
}),
|
||||
])
|
||||
const briefReport = buildDossierReport({ snapshot, research: briefedResearch, generatedAt })
|
||||
const html = renderDossierHtml(briefReport)
|
||||
const md = renderDossierMd(briefReport)
|
||||
|
||||
it('prints the short brief instead of the full record', () => {
|
||||
expect(html).toContain('Manchete reformulada para caber')
|
||||
expect(html).toContain('Nota reformulada curta.')
|
||||
expect(html).not.toContain('Resposta integral bem longa')
|
||||
})
|
||||
|
||||
it('never truncates with an ellipsis', () => {
|
||||
expect(html).not.toContain('…')
|
||||
})
|
||||
|
||||
it('keeps the full record in the .md companion', () => {
|
||||
expect(md).toContain('Resposta integral bem longa')
|
||||
})
|
||||
|
||||
it('paginates long news lists into continuation sheets', () => {
|
||||
expect(html).toContain('data-page="noticias"')
|
||||
expect(html).toContain('data-page="noticias-2"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderDossierHtml — era continuation sheets', () => {
|
||||
const manyActions = mergeDossierResearch([
|
||||
normalizeDossierResearchInput({
|
||||
municipalitySlug: 'ilheus',
|
||||
era: 'C',
|
||||
researchedAt: '2026-09-16T10:00:00.000Z',
|
||||
items: ['discursos', 'proposicoes', 'emendas', 'titulos', 'atuacao'].map((suffix, index) => ({
|
||||
id: `era_c_${suffix}`,
|
||||
answer: `Ação ${index}`,
|
||||
brief: { title: `Ação curta ${index}`, note: `Nota ${index}` },
|
||||
sourceUrl: `https://exemplo.test/acao-${index}`,
|
||||
sourceDate: '2026-09-10',
|
||||
})),
|
||||
news: [],
|
||||
gaps: [],
|
||||
}),
|
||||
])
|
||||
const report = buildDossierReport({ snapshot, research: manyActions, generatedAt })
|
||||
const html = renderDossierHtml(report)
|
||||
|
||||
it('paginates era action cards into continuation sheets instead of dropping them', () => {
|
||||
expect(html).toContain('data-page="era-c"')
|
||||
expect(html).toContain('data-page="era-c-2"')
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
expect(html).toContain(`Ação curta ${index}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderDossierMd', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DOSSIER_BRIEF_NOTE_MAX,
|
||||
DOSSIER_BRIEF_TITLE_MAX,
|
||||
DOSSIER_RESEARCH_CHECKLIST_IDS,
|
||||
dossierChecklistForEra,
|
||||
dossierResearchReceipt,
|
||||
|
|
@ -41,6 +43,41 @@ describe('normalizeDossierResearchInput', () => {
|
|||
expect(research.items.every((item) => item.sphere === 'municipio')).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes the reformulated brief and drops it when it has no title', () => {
|
||||
const research = normalizeDossierResearchInput(
|
||||
eraResearch('C', {
|
||||
items: [
|
||||
validItem('era_c_emendas', { brief: { title: 'Manchete curta', note: 'Nota curta' } }),
|
||||
validItem('era_c_discursos', { brief: { note: 'sem título' } }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const emendas = research.items.find((item) => item.id === 'era_c_emendas')
|
||||
const discursos = research.items.find((item) => item.id === 'era_c_discursos')
|
||||
expect(emendas?.brief).toEqual({ title: 'Manchete curta', note: 'Nota curta' })
|
||||
expect(discursos?.brief).toBeNull()
|
||||
})
|
||||
|
||||
it('warns when a brief exceeds the print budget (kept, so the A4 guard catches it)', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const research = normalizeDossierResearchInput(
|
||||
eraResearch('C', {
|
||||
items: [
|
||||
validItem('era_c_emendas', {
|
||||
brief: { title: 'x'.repeat(DOSSIER_BRIEF_TITLE_MAX + 1) },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(warn).toHaveBeenCalled()
|
||||
expect(research.items[0].brief?.title.length).toBe(DOSSIER_BRIEF_TITLE_MAX + 1)
|
||||
expect(DOSSIER_BRIEF_NOTE_MAX).toBeGreaterThan(0)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('throws when the slug, the date or the era is missing', () => {
|
||||
expect(() => normalizeDossierResearchInput(eraResearch('A', { municipalitySlug: '' }))).toThrow(
|
||||
/municipalitySlug/,
|
||||
|
|
|
|||
Loading…
Reference in a new issue