From e57dabe04b354d5316d8be52729a05fb5ed29be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mar=C3=A7ura?= Date: Sat, 30 May 2026 15:43:38 -0300 Subject: [PATCH 1/2] feat(web): compositor de posts + enriquecimento Wikipedia (WIP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fundação do feed de POSTS (Onda 2) — ainda não integrado no Início. - post-compositor.ts: transforma civic_event seco em post legível — título humano ("Prefeitura contratou X por R$ Y"), linha fina, valor legível, capa temática (gradiente+ícone), disclaimer de sanção, e CONEXÕES clicáveis (empresa→/empresa/CNPJ, pessoa→/pessoa/slug, órgão→/orgao/slug, termos cívicos com tooltip do glossário) - wikipedia.ts: enriquecimento via REST PT (foto/resumo/Wikidata) para cidade e órgãos — nunca pessoas comuns (doutrina). Cache 7 dias. Validado sobre dados reais. Próximo: integrar no Início como feed de posts visual + drill-down, e remover /eventos. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/lib/post-compositor.ts | 268 ++++++++++++++++++++++++++++ apps/web/src/lib/wikipedia.ts | 81 +++++++++ 2 files changed, 349 insertions(+) create mode 100644 apps/web/src/lib/post-compositor.ts create mode 100644 apps/web/src/lib/wikipedia.ts diff --git a/apps/web/src/lib/post-compositor.ts b/apps/web/src/lib/post-compositor.ts new file mode 100644 index 0000000..7bde501 --- /dev/null +++ b/apps/web/src/lib/post-compositor.ts @@ -0,0 +1,268 @@ +/** + * Compositor de POSTS — o coração do feed cívico. + * + * Transforma um evento "seco" do banco (civic_events) num POST excelente de + * leitura, estilo Instagram: título humano, uma linha fina clara, valor legível, + * capa temática (gradiente + ícone) e disclaimers fiéis à doutrina. + * + * Módulo PURO (sem banco, sem server-only) — testável isoladamente e reaproveitável + * tanto no Início quanto em páginas de detalhe. + * + * Doutrina respeitada: + * - Sanção mostra aviso "não indica culpa". + * - Nada de acusação; linguagem neutra ("contratou", "pagou", "recebeu"). + * - Fonte sempre presente (quem chama o post mostra a fonte/evidência). + */ + +export interface EventoParaPost { + categoria: string; + titulo: string; + resumo: string | null; + valor: string | null; + sourceId: string; + /** Referências do evento (jsonb civic_events.entidades), quando houver. */ + entidades?: { + fornecedorDocumento?: string | null; + /** Nome do órgão já resolvido pelo read model (join em entities), opcional. */ + orgaoNome?: string | null; + } | null; +} + +/** Uma conexão navegável a partir do post (o "clicar no nome e ir fundo"). */ +export interface Conexao { + tipo: "empresa" | "orgao" | "pessoa" | "termo"; + label: string; + /** Destino do clique (drill-down). Ausente em "termo" puro (só tooltip). */ + href?: string; + /** Explicação curta para termos cívicos (glossário inline). */ + tooltip?: string; +} + +export interface PostCapa { + /** Classe Tailwind de gradiente para a capa (a "imagem" base do post). */ + gradiente: string; + emoji: string; +} + +export interface Post { + /** Título humano, pronto pra ler — substitui o título seco do banco. */ + titulo: string; + /** Uma linha de contexto em linguagem simples (objeto, finalidade…). */ + linhaFina: string | null; + /** Valor legível e abreviado: "R$ 12,6 milhões". null quando não há. */ + valorLabel: string | null; + selo: { emoji: string; label: string }; + capa: PostCapa; + /** Aviso obrigatório quando o tipo exige (ex.: sanção). */ + disclaimer: string | null; + /** Conexões clicáveis — empresa, órgão, pessoa, termos. O coração do "conectar tudo". */ + conexoes: Conexao[]; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** "R$ 12,6 milhões" · "R$ 56 mil" · "R$ 1.234,00" — legível pro cidadão. */ +export function valorHumano(valor: string | number | null): string | null { + if (valor === null) return null; + const n = typeof valor === "number" ? valor : Number(valor); + if (!Number.isFinite(n) || n === 0) return null; + if (n >= 1_000_000_000) return `R$ ${(n / 1_000_000_000).toLocaleString("pt-BR", { maximumFractionDigits: 1 })} bilhões`; + if (n >= 1_000_000) return `R$ ${(n / 1_000_000).toLocaleString("pt-BR", { maximumFractionDigits: 1 })} milhões`; + if (n >= 10_000) return `R$ ${Math.round(n / 1000).toLocaleString("pt-BR")} mil`; + return n.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }); +} + +/** Extrai "Label: valor" do resumo semi-estruturado (até o próximo ". " ou fim). */ +function extrair(resumo: string | null, label: string): string | null { + if (!resumo) return null; + const re = new RegExp(`${label}\\s*:?\\s*([^.]+?)(?:\\.\\s|\\.$|$)`, "i"); + const m = resumo.match(re); + return m?.[1]?.trim() || null; +} + +const CONECTIVOS = new Set([ + "de", "da", "do", "das", "dos", "e", "a", "o", "as", "os", + "em", "para", "por", "com", "no", "na", "ao", "pela", "pelo", "pelas", "pelos", +]); + +/** Title Case em pt-BR: conectivos minúsculos, siglas preservadas só em texto MISTO. */ +function titleCasePt(s: string): string { + // Texto todo-maiúsculo (PNCP/PDF) → não dá pra distinguir sigla de palavra; faz + // title case puro. Só preserva siglas quando há minúsculas (token all-caps = sigla). + const textoMisto = /[a-zà-ÿ]/.test(s); + return s + .split(/\s+/) + .map((w, i) => { + const lw = w.toLocaleLowerCase("pt-BR"); + if (i > 0 && CONECTIVOS.has(lw)) return lw; // conectivo minúsculo (antes da sigla) + if (textoMisto && w.length >= 2 && w.length <= 5 && /[A-ZÀ-Ú]/.test(w) && w === w.toLocaleUpperCase("pt-BR")) { + return w; // sigla preservada (ex.: "Empresa XPTO Ltda") + } + return lw.charAt(0).toLocaleUpperCase("pt-BR") + lw.slice(1); + }) + .join(" "); +} + +/** Capitaliza e encurta um nome próprio/empresa pra caber no título. */ +function nomeCurto(s: string | null, max = 48): string | null { + if (!s) return null; + const tc = titleCasePt(s.replace(/\s+/g, " ").trim()); + return tc.length > max ? tc.slice(0, max - 1).trimEnd() + "…" : tc; +} + +/** Proporção de letras maiúsculas (ignora dígitos/pontuação). */ +function fracaoMaiusculas(s: string): number { + const letras = s.replace(/[^A-Za-zÀ-ÿ]/g, ""); + if (!letras) return 0; + const maius = letras.replace(/[^A-ZÀ-Þ]/g, "").length; + return maius / letras.length; +} + +/** Objeto/contexto em caixa de leitura: corta metadados, normaliza CAIXA ALTA do PDF. */ +function objetoLegivel(resumo: string | null, max = 150): string | null { + if (!resumo) return null; + // Tira os campos estruturados que viram chips (Fornecedor/Credor/Valor/Órgão…). + let texto = resumo + .split(/\b(?:Fornecedor|Credor|Valor registrado|Valor arrecadado|Maior fase|Tipo|Fonte|Órg[ãa]o|Ação or[çc])/i)[0] + .replace(/,?\s*conforme.*$/i, "") + .replace(/\s+/g, " ") + .trim(); + if (!texto) return null; + // PDFs vêm majoritariamente em CAIXA ALTA → sentence case quando >60% maiúsculas. + if (fracaoMaiusculas(texto) > 0.6 && texto.length > 12) { + texto = texto.toLocaleLowerCase("pt-BR"); + texto = texto.charAt(0).toLocaleUpperCase("pt-BR") + texto.slice(1); + } + return texto.length > max ? texto.slice(0, max - 1).trimEnd() + "…" : texto; +} + +const CAPA: Record = { + contratacao: { gradiente: "from-emerald-500/15 to-teal-500/10", emoji: "📋" }, + pagamento: { gradiente: "from-sky-500/15 to-indigo-500/10", emoji: "💸" }, + receita: { gradiente: "from-amber-500/15 to-yellow-500/10", emoji: "🪙" }, + ato_normativo: { gradiente: "from-violet-500/15 to-fuchsia-500/10", emoji: "📜" }, + nomeacao_exoneracao: { gradiente: "from-rose-500/15 to-orange-500/10", emoji: "👤" }, + sancao: { gradiente: "from-red-500/20 to-orange-500/10", emoji: "⚠️" }, + audiencia_conselho: { gradiente: "from-blue-500/15 to-cyan-500/10", emoji: "🏛️" }, + obra_zeladoria: { gradiente: "from-orange-500/15 to-amber-500/10", emoji: "🚧" }, +}; + +const SELO: Record = { + contratacao: "Contrato", + pagamento: "Pagamento", + receita: "Arrecadação", + ato_normativo: "Ato oficial", + nomeacao_exoneracao: "Cargo público", + sancao: "Sanção", + audiencia_conselho: "Audiência", + obra_zeladoria: "Obra", +}; + +// ── Compositor ───────────────────────────────────────────────────────────── + +/** slug estável em pt-BR (sem acento, minúsculo, hifenizado) para /orgao, /pessoa. */ +function slugify(s: string): string { + return s + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** Termos cívicos explicados em 1 linha — o glossário que vira tooltip clicável. */ +const GLOSSARIO: Array<{ re: RegExp; label: string; tooltip: string }> = [ + { re: /pregão eletrônico/i, label: "Pregão eletrônico", tooltip: "Disputa de preços online; vence quem oferece o menor valor cumprindo as regras." }, + { re: /pregão/i, label: "Pregão", tooltip: "Modalidade de licitação para compras comuns; vence o menor preço." }, + { re: /dispensa de licitação|dispensa/i, label: "Dispensa de licitação", tooltip: "Contratação direta, sem licitação, permitida por lei em casos específicos." }, + { re: /inexigibilidade/i, label: "Inexigibilidade", tooltip: "Contratação sem licitação quando só há um fornecedor possível." }, + { re: /concorrência/i, label: "Concorrência", tooltip: "Modalidade de licitação para contratos de maior valor." }, + { re: /termo aditivo|aditivo/i, label: "Termo aditivo", tooltip: "Alteração de um contrato já existente (prazo, valor ou objeto)." }, + { re: /empenhad|empenho/i, label: "Empenho", tooltip: "Primeira fase do gasto: o governo reserva o dinheiro para a despesa." }, + { re: /liquidad|liquidação/i, label: "Liquidação", tooltip: "O governo confirma que o serviço/produto foi entregue." }, + { re: /\bpago\b|pagamento/i, label: "Pagamento", tooltip: "Fase final: o dinheiro efetivamente sai dos cofres públicos." }, +]; + +/** Detecta termos cívicos no texto e os transforma em conexões com tooltip. */ +function detectarTermos(texto: string, limite = 3): Conexao[] { + const out: Conexao[] = []; + const vistos = new Set(); + for (const g of GLOSSARIO) { + if (out.length >= limite) break; + if (g.re.test(texto) && !vistos.has(g.label)) { + vistos.add(g.label); + out.push({ tipo: "termo", label: g.label, tooltip: g.tooltip }); + } + } + return out; +} + +export function comporPost(e: EventoParaPost): Post { + const capa = CAPA[e.categoria] ?? { gradiente: "from-foreground/10 to-foreground/5", emoji: "•" }; + const selo = { emoji: capa.emoji, label: SELO[e.categoria] ?? e.categoria }; + const valorLabel = valorHumano(e.valor); + let titulo = e.titulo; + let linhaFina = objetoLegivel(e.resumo); + let disclaimer: string | null = null; + const conexoes: Conexao[] = []; + + // Empresa: CNPJ do fornecedor → página da empresa (drill-down financeiro). + const cnpj = e.entidades?.fornecedorDocumento?.replace(/\D/g, "") ?? null; + + switch (e.categoria) { + case "contratacao": { + const fornecedor = nomeCurto(extrair(e.resumo, "Fornecedor")); + titulo = fornecedor + ? `Prefeitura contratou ${fornecedor}${valorLabel ? ` por ${valorLabel}` : ""}` + : `Prefeitura fez uma contratação${valorLabel ? ` de ${valorLabel}` : ""}`; + if (cnpj?.length === 14) conexoes.push({ tipo: "empresa", label: fornecedor ?? "Empresa contratada", href: `/empresa/${cnpj}` }); + break; + } + case "pagamento": { + const credor = nomeCurto(extrair(e.resumo, "Credor")); + titulo = credor + ? `Prefeitura pagou ${valorLabel ?? "uma despesa"} a ${credor}` + : `Prefeitura registrou pagamento${valorLabel ? ` de ${valorLabel}` : ""}`; + linhaFina = nomeCurto(extrair(e.resumo, "Ação orçament[áa]ria"), 120) ?? linhaFina; + if (cnpj?.length === 14 && credor) conexoes.push({ tipo: "empresa", label: credor, href: `/empresa/${cnpj}` }); + break; + } + case "receita": { + const tipo = nomeCurto(e.titulo.replace(/^Receita arrecadada:\s*\d*\s*-?\s*/i, ""), 60); + titulo = `Prefeitura arrecadou ${valorLabel ?? "receita"}${tipo ? ` — ${tipo}` : ""}`; + break; + } + case "sancao": { + const empresa = nomeCurto(e.resumo?.split(/\bTipo\b/i)[0] ?? null); + const tipo = nomeCurto(extrair(e.resumo, "Tipo"), 70); + titulo = empresa ? `${empresa} consta em lista de sanção` : "Sanção registrada"; + linhaFina = tipo; + disclaimer = "Sinal oficial de sanção — não indica culpa nem irregularidade; veja a fonte."; + if (cnpj?.length === 14 && empresa) conexoes.push({ tipo: "empresa", label: empresa, href: `/empresa/${cnpj}` }); + break; + } + case "nomeacao_exoneracao": { + const partes = (e.resumo ?? "").split("·").map((s) => s.trim()); + const nome = nomeCurto(partes[0] ?? null, 40); + const cargo = partes[1] ?? null; + titulo = nome ? `${e.titulo}: ${nome}` : e.titulo; + linhaFina = cargo ?? linhaFina; + // Agente público nomeado/exonerado é informação pública → drill-down na pessoa. + if (nome) conexoes.push({ tipo: "pessoa", label: nome, href: `/pessoa/${slugify(nome)}` }); + break; + } + // ato_normativo / audiencia_conselho / obra_zeladoria: título do banco + linha fina. + } + + // Órgão: nome resolvido pelo read model (join), ou "Órgão: X" no resumo. + const orgao = e.entidades?.orgaoNome ?? extrair(e.resumo, "Órg[ãa]o"); + const orgaoNome = nomeCurto(orgao, 50); + if (orgaoNome && !/prefeitura municipal de americana/i.test(orgaoNome)) { + conexoes.push({ tipo: "orgao", label: orgaoNome, href: `/orgao/${slugify(orgaoNome)}` }); + } + + // Termos cívicos (glossário inline) a partir de título + resumo. + conexoes.push(...detectarTermos(`${e.titulo} ${e.resumo ?? ""}`)); + + return { titulo, linhaFina, valorLabel, selo, capa, disclaimer, conexoes }; +} diff --git a/apps/web/src/lib/wikipedia.ts b/apps/web/src/lib/wikipedia.ts new file mode 100644 index 0000000..57ced73 --- /dev/null +++ b/apps/web/src/lib/wikipedia.ts @@ -0,0 +1,81 @@ +/** + * Enriquecimento via Wikipedia (REST API em português) — fotos, resumo e fatos + * para dar contexto humano aos posts: a cidade, os órgãos públicos, lugares. + * + * Decisão de stack: usa a API REST pública da Wikipedia direto via `fetch` + * (sem dependência Python/lib externa). Cache de 7 dias via Next. + * + * DOUTRINA: enriquecer apenas ENTIDADES PÚBLICAS (cidade, órgãos, lugares). + * NUNCA buscar pessoas físicas comuns — só cabe a agente público notório, e ainda + * assim com cautela. Por isso o enriquecimento é opt-in por tipo, nunca automático + * sobre nomes de pessoas. + */ +import "server-only"; + +export interface WikiInfo { + titulo: string; + resumo: string | null; + /** URL da imagem (thumbnail) — a "foto" do post. */ + foto: string | null; + /** Link para a página completa na Wikipedia. */ + url: string | null; + /** ID Wikidata (ex.: Q467421) — porta para fatos estruturados no futuro. */ + wikidataId: string | null; +} + +const REST = "https://pt.wikipedia.org/api/rest_v1/page/summary/"; + +// Cache em processo (além do cache HTTP do Next) — evita refetch no mesmo render. +const _cache = new Map(); + +/** + * Busca resumo + foto de um título na Wikipedia PT. Retorna null se não existir, + * for desambiguação, ou em qualquer falha (degrada sem quebrar o post). + */ +export async function buscarWikipedia(titulo: string): Promise { + const chave = titulo.trim(); + if (!chave) return null; + if (_cache.has(chave)) return _cache.get(chave) ?? null; + + try { + const res = await fetch(REST + encodeURIComponent(chave.replace(/\s+/g, "_")), { + headers: { accept: "application/json" }, + // 7 dias: fatos da Wikipedia mudam devagar; não martelar a API. + next: { revalidate: 604_800 }, + }); + if (!res.ok) { + _cache.set(chave, null); + return null; + } + const d = (await res.json()) as { + type?: string; + title?: string; + extract?: string; + thumbnail?: { source?: string }; + content_urls?: { desktop?: { page?: string } }; + wikibase_item?: string; + }; + // Páginas de desambiguação não servem como contexto confiável. + if (d.type === "disambiguation" || !d.title) { + _cache.set(chave, null); + return null; + } + const info: WikiInfo = { + titulo: d.title, + resumo: d.extract?.trim() || null, + foto: d.thumbnail?.source ?? null, + url: d.content_urls?.desktop?.page ?? null, + wikidataId: d.wikibase_item ?? null, + }; + _cache.set(chave, info); + return info; + } catch { + _cache.set(chave, null); + return null; + } +} + +/** Contexto da cidade-piloto (foto + resumo de Americana) para o cabeçalho do feed. */ +export function contextoCidade(): Promise { + return buscarWikipedia("Americana (São Paulo)"); +} From be3a80ce593249647ed70a30763d255e4211f28d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mar=C3=A7ura?= Date: Sat, 30 May 2026 15:57:29 -0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(web):=20In=C3=ADcio=20vira=20o=20feed?= =?UTF-8?q?=20de=20posts=20vivo=20(unifica=20e=20d=C3=A1=20vida)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onda 2 completa — uma rede social cívica num lugar só. - eventos.ts: getEventosRanqueados — algoritmo de relevância (categoria + valor log + recência) com COTAS por categoria (window) e round-robin, pra o feed ser variado e "respirar" (contratos não afogam sanções/atos/nomeações) - PostCard: cada acontecimento como post estilo Instagram — capa temática, título humano, valor legível, conexões CLICÁVEIS (empresa→CNPJ, pessoa, órgão, termos com tooltip), disclaimer de sanção, fonte + ver documento - Início (/): agora é o feed de posts lendo civic_events (130k) — substitui o feed de átomos. /eventos → redirect pra / (resolve "tudo isso ser 1") Validado live: 30 posts variados, conexões empresa/pessoa/órgão clicáveis, sanção com aviso. Build/lint OK (/ é dynamic). Co-Authored-By: Claude Opus 4.8 --- apps/web/src/app/eventos/page.tsx | 181 +-------------------- apps/web/src/app/page.tsx | 95 +++++------ apps/web/src/components/feed/post-card.tsx | 102 ++++++++++++ apps/web/src/lib/eventos.ts | 88 ++++++++++ 4 files changed, 235 insertions(+), 231 deletions(-) create mode 100644 apps/web/src/components/feed/post-card.tsx diff --git a/apps/web/src/app/eventos/page.tsx b/apps/web/src/app/eventos/page.tsx index 58707f0..7dae23f 100644 --- a/apps/web/src/app/eventos/page.tsx +++ b/apps/web/src/app/eventos/page.tsx @@ -1,180 +1,9 @@ /** - * Linha do tempo cívica — a fundação de dados (civic_events) finalmente VISÍVEL. - * - * Lista os acontecimentos verificáveis de Americana vindos do banco canônico: - * pagamentos, contratos, atos do diário, sanções, receitas. Cada item mostra - * FONTE e (quando há) VALOR — fonte antes de conclusão, sempre. - * - * force-dynamic: lê o Postgres em runtime (não prerenderiza no build sem banco). + * /eventos foi UNIFICADO no Início (/). Esta rota agora só redireciona, pra não + * ter duas superfícies de feed (feedback do Pedro: "tudo isso ser 1"). */ -import Link from "next/link"; -import { ExternalLink } from "lucide-react"; -import { AppShell } from "@/components/app-shell/app-shell"; -import { GeoBreadcrumb } from "@/components/feed/geo-breadcrumb"; -import { EmptyState } from "@/components/feed/empty-state"; -import { - getEventos, - getEventosStats, - categoriaMeta, - fonteLabel, - valorBRL, - dataExtensa, - type CategoriaEvento, -} from "@/lib/eventos"; +import { redirect } from "next/navigation"; -export const dynamic = "force-dynamic"; - -const CATS_VALIDAS: CategoriaEvento[] = [ - "contratacao", - "pagamento", - "receita", - "ato_normativo", - "nomeacao_exoneracao", - "sancao", - "audiencia_conselho", - "obra_zeladoria", -]; - -interface PageProps { - searchParams: Promise<{ cat?: string }>; -} - -export default async function EventosPage({ searchParams }: PageProps) { - const sp = await searchParams; - const cat = (CATS_VALIDAS as readonly string[]).includes(sp.cat ?? "") - ? (sp.cat as CategoriaEvento) - : undefined; - - const [eventos, stats] = await Promise.all([ - getEventos({ categoria: cat, limit: 50 }), - getEventosStats(), - ]); - - return ( - -
-

linha do tempo cívica

-

- O que aconteceu em Americana -

-

- {stats.total > 0 - ? `${stats.total.toLocaleString("pt-BR")} acontecimentos públicos verificáveis — cada um com a fonte oficial.` - : "Conecte o banco (DATABASE_URL) para ver os eventos coletados."} -

-
- -
- -
- - {/* Filtros por categoria (com contagem real) */} - - - {/* Lista de eventos */} -
- {eventos.length === 0 ? ( - - ) : ( - eventos.map((e) => ) - )} -
-
- ); -} - -function FiltroChip({ - label, - emoji, - href, - ativo, -}: { - label: string; - emoji: string; - href: string; - ativo: boolean; -}) { - return ( - - {emoji} - {label} - - ); -} - -function EventoCard({ evento }: { evento: Awaited>[number] }) { - const meta = categoriaMeta(evento.categoria); - const valor = valorBRL(evento.valor); - - return ( -
-
- - {meta.emoji} - {meta.label} - - {dataExtensa(evento.dataEvento)} -
- -

- {evento.titulo} -

- - {valor && ( -

{valor}

- )} - - {evento.resumo && ( -

{evento.resumo}

- )} - - -
- ); +export default function EventosPage() { + redirect("/"); } diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 5930a28..3a46bd9 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,26 +1,23 @@ /** - * Home — feed cívico de Americana montado por ÁTOMOS reais. + * Início — o FEED CÍVICO de Americana, unificado. * - * Stories são o ÚNICO filtro (Pedro: "só as bolinhas, nada de duas formas"). - * Cada post no feed é um ato individual extraído do diário. - * - 1 contrato = 1 post (com valor R$ destacado) - * - 1 lei = 1 post - * - 1 decreto/portaria = 1 post - * - 1 pregão/edital = 1 post + * Uma rede social viva: cada acontecimento público (contrato, pagamento, ato, + * sanção) vira um POST legível e navegável, vindo do banco canônico (civic_events, + * ~130 mil eventos das fontes oficiais). O algoritmo de relevância (getEventosRanqueados) + * dá vida ao feed — dinheiro e recência sobem, os 124k pagamentos não afogam. * - * Algoritmo de relevância: atos de dinheiro pesam mais, com CNPJ pesa mais, - * recente pesa mais — ranqueia o feed em vez de só ordenar por data. + * Substitui as antigas superfícies separadas (início de átomos + /eventos): agora + * é UM lugar só, alimentado automaticamente pelo que os coletores trazem. */ -import Link from "next/link"; import { Eye } from "lucide-react"; import { AppShell } from "@/components/app-shell/app-shell"; -import { AtomCard } from "@/components/feed/atom-card"; +import { PostCard } from "@/components/feed/post-card"; import { StoriesRow, type StoryItem } from "@/components/feed/stories-row"; import { GeoBreadcrumb } from "@/components/feed/geo-breadcrumb"; import { EmptyState } from "@/components/feed/empty-state"; -import { getAtomsRanqueados, getAtomsStats, type CategoriaFeed } from "@/lib/atoms"; +import { getEventosRanqueados, getEventosStats, type CategoriaEvento } from "@/lib/eventos"; -export const revalidate = 600; +export const dynamic = "force-dynamic"; function saudacao(now = new Date()): string { const h = now.getHours(); @@ -30,7 +27,14 @@ function saudacao(now = new Date()): string { return "Boa noite"; } -const CATEGORIAS_VALIDAS: CategoriaFeed[] = ["tudo", "dinheiro", "leis", "atos", "convenios"]; +const CATS_VALIDAS: CategoriaEvento[] = [ + "contratacao", + "pagamento", + "ato_normativo", + "sancao", + "nomeacao_exoneracao", + "receita", +]; interface PageProps { searchParams: Promise<{ cat?: string }>; @@ -38,39 +42,36 @@ interface PageProps { export default async function HomePage({ searchParams }: PageProps) { const sp = await searchParams; - const cat: CategoriaFeed = (CATEGORIAS_VALIDAS as readonly string[]).includes(sp.cat ?? "") - ? (sp.cat as CategoriaFeed) - : "tudo"; + const cat = (CATS_VALIDAS as readonly string[]).includes(sp.cat ?? "") + ? (sp.cat as CategoriaEvento) + : undefined; - const [atoms, stats] = await Promise.all([ - getAtomsRanqueados({ categoria: cat, limit: 20 }), - getAtomsStats(), + const [eventos, stats] = await Promise.all([ + getEventosRanqueados({ categoria: cat, limit: 30 }), + getEventosStats(), ]); - // Stories = filtros principais. Sem duplicação com chip bar. const stories: StoryItem[] = [ - { id: "tudo", label: "tudo", href: "/", iniciais: "✨", ativo: cat === "tudo", bg: "bg-foreground/8", fg: "text-foreground", novo: cat !== "tudo" }, - { id: "dinheiro", label: "dinheiro", href: "/?cat=dinheiro", iniciais: "💰", ativo: cat === "dinheiro" }, - { id: "leis", label: "leis", href: "/?cat=leis", iniciais: "📜", ativo: cat === "leis" }, - { id: "atos", label: "atos", href: "/?cat=atos", iniciais: "📃", ativo: cat === "atos" }, - { id: "convenios", label: "convênios", href: "/?cat=convenios", iniciais: "🤝", ativo: cat === "convenios" }, + { id: "tudo", label: "tudo", href: "/", iniciais: "✨", ativo: !cat, bg: "bg-foreground/8", fg: "text-foreground", novo: !!cat }, + { id: "contratacao", label: "contratos", href: "/?cat=contratacao", iniciais: "📋", ativo: cat === "contratacao" }, + { id: "pagamento", label: "pagamentos", href: "/?cat=pagamento", iniciais: "💸", ativo: cat === "pagamento" }, + { id: "ato_normativo", label: "atos", href: "/?cat=ato_normativo", iniciais: "📜", ativo: cat === "ato_normativo" }, + { id: "sancao", label: "sanções", href: "/?cat=sancao", iniciais: "⚠️", ativo: cat === "sancao" }, + { id: "nomeacao_exoneracao", label: "cargos", href: "/?cat=nomeacao_exoneracao", iniciais: "👤", ativo: cat === "nomeacao_exoneracao" }, ]; return ( - {/* Saudação */}
de olho em americana
-

- {saudacao()} 👋 -

+

{saudacao()} 👋

- {atoms.length > 0 - ? `${stats.total} atos extraídos do diário. ${cat === "tudo" ? "Veja o que rolou." : `Filtrando por ${cat}.`}` - : "Ainda não temos nada nessa categoria coletado."} + {eventos.length > 0 + ? `${stats.total.toLocaleString("pt-BR")} acontecimentos públicos. ${cat ? "Filtrando." : "Veja o que rolou na cidade."}` + : "Conecte o banco (DATABASE_URL) para o feed ganhar vida."}

@@ -84,40 +85,24 @@ export default async function HomePage({ searchParams }: PageProps) { /> - {/* Stories = único filtro */}
- +
- {/* Feed atômico ranqueado */}
- {atoms.length === 0 ? ( + {eventos.length === 0 ? ( - voltar pra tudo - - } + titulo="Nada por aqui ainda" + descricao="O coletor roda periodicamente. Se o banco estiver vazio, rode os mapeadores (map:pncp, map:tce, map:diario-atoms)." /> ) : ( - atoms.map((a) => ) - )} - - {atoms.length >= 20 && ( - - ver tudo no radar - + eventos.map((e) => ) )}

- {stats.total} atos atômicos · {stats.edicoesProcessadas} edições reais do diário + {stats.total.toLocaleString("pt-BR")} acontecimentos públicos de Americana · fontes oficiais

); diff --git a/apps/web/src/components/feed/post-card.tsx b/apps/web/src/components/feed/post-card.tsx new file mode 100644 index 0000000..f726724 --- /dev/null +++ b/apps/web/src/components/feed/post-card.tsx @@ -0,0 +1,102 @@ +/** + * PostCard — um acontecimento público como POST (estilo feed/Instagram). + * + * Recebe um evento ranqueado, compõe o post (título humano, valor, capa, conexões) + * e renderiza de forma legível e navegável: cada conexão (empresa, pessoa, órgão, + * termo) é clicável, levando o cidadão a ir fundo na informação. + */ +import Link from "next/link"; +import { ExternalLink } from "lucide-react"; +import { comporPost } from "@/lib/post-compositor"; +import { fonteLabel, dataExtensa, type EventoRanqueado } from "@/lib/eventos"; + +export function PostCard({ evento }: { evento: EventoRanqueado }) { + const post = comporPost(evento); + + return ( +
+ {/* Capa temática — a "imagem" do post (gradiente + ícone + valor em destaque) */} +
+ + {post.selo.emoji} + {post.selo.label} + + {post.valorLabel && ( + + {post.valorLabel} + + )} +
+ +
+

{post.titulo}

+ {post.linhaFina && ( +

{post.linhaFina}

+ )} + + {post.disclaimer && ( +

+ {post.disclaimer} +

+ )} + + {/* Conexões clicáveis — o "navegar clicando nos nomes" */} + {post.conexoes.length > 0 && ( +
+ {post.conexoes.map((c, i) => + c.href ? ( + + {iconePorTipo(c.tipo)} {c.label} + + ) : ( + + {iconePorTipo(c.tipo)} {c.label} + + ), + )} +
+ )} +
+ +
+ + {fonteLabel(evento.sourceId)} · {dataExtensa(evento.dataEvento)} + + {evento.sourceUrl && ( + + ver documento + + + )} +
+
+ ); +} + +function iconePorTipo(tipo: string): string { + switch (tipo) { + case "empresa": + return "🏢"; + case "pessoa": + return "👤"; + case "orgao": + return "🏛️"; + case "termo": + return "📖"; + default: + return "•"; + } +} diff --git a/apps/web/src/lib/eventos.ts b/apps/web/src/lib/eventos.ts index a3ca41d..4a1cba9 100644 --- a/apps/web/src/lib/eventos.ts +++ b/apps/web/src/lib/eventos.ts @@ -112,6 +112,94 @@ export async function getEventosStats(): Promise { } } +// ── Algoritmo do feed — dá "vida" à rede social ────────────────────────────── +/** + * Ranking de relevância: sem ele o feed seria 124k pagamentos por data. Pesa + * tipo (contrato/sanção no topo, pagamento no fim), valor (log) e recência (leve, + * porque os dados se concentram nos últimos meses). Depois aplica diversidade + * pra não repetir a mesma categoria em sequência — feed que "respira". + */ +export interface EventoRanqueado extends EventoCivico { + entidades: { fornecedorDocumento?: string | null; orgaoNome?: string | null } | null; +} + +// Score de relevância (texto SQL reutilizado no select e no row_number). +const SCORE_SQL = `( + case categoria + when 'contratacao' then 30 when 'sancao' then 28 + when 'ato_normativo' then 16 when 'nomeacao_exoneracao' then 14 + when 'obra_zeladoria' then 14 when 'audiencia_conselho' then 10 + when 'receita' then 8 when 'pagamento' then 2 else 5 end + + least(40, coalesce(ln(greatest(valor, 1)) * 3, 0)) + + case when data_evento is null then 0 + when current_date - data_evento < 30 then 20 + when current_date - data_evento < 90 then 12 + when current_date - data_evento < 365 then 5 else 0 end +)`; + +/** + * Round-robin por categoria: pega 1 de cada categoria por rodada (cada grupo já + * vem ordenado por score). Garante que sanções/atos/nomeações apareçam no topo do + * feed em vez de serem afogados por contratos — feed variado, que "respira". + */ +function diversificar(rows: T[]): T[] { + const grupos = new Map(); + for (const r of rows) { + const g = grupos.get(r.categoria); + if (g) g.push(r); + else grupos.set(r.categoria, [r]); + } + const ordem = [...grupos.keys()]; // ordem de primeira aparição = por score + const out: T[] = []; + let restam = true; + while (restam) { + restam = false; + for (const cat of ordem) { + const g = grupos.get(cat)!; + if (g.length) { + out.push(g.shift()!); + restam = true; + } + } + } + return out; +} + +/** + * Feed vivo: usa COTAS por categoria (row_number) pra garantir variedade — sem + * isso, contratos (score alto) afogariam sanções, atos e nomeações. Depois ordena + * por score e diversifica. Quando há filtro de categoria, traz só ela (sem cota). + */ +export async function getEventosRanqueados(filtro: FiltroEventos = {}): Promise { + const db = getDb(); + if (!db) return []; + const { categoria, limit = 30 } = filtro; + const cotaPorCategoria = categoria ? 120 : 14; // sem filtro: até 14 de cada categoria + try { + const result = await db.execute(sql` + select id, categoria, tipo, titulo, resumo, valor::text as valor, + source_id as "sourceId", source_url as "sourceUrl", + data_evento as "dataEvento", entidades, + published_at as "publishedAt", fetched_at as "fetchedAt", + limitacoes, trust_type as "trustType" + from ( + select *, ${sql.raw(SCORE_SQL)} as _score, + row_number() over (partition by categoria order by ${sql.raw(SCORE_SQL)} desc, data_evento desc nulls last) as _rn + from civic_events + ${categoria ? sql`where categoria = ${categoria}` : sql``} + ) t + where _rn <= ${cotaPorCategoria} + order by _score desc + limit 120 + `); + const rows = (Array.isArray(result) ? result : (result as { rows?: unknown[] }).rows ?? []) as EventoRanqueado[]; + return diversificar(rows).slice(0, limit); + } catch (e) { + console.warn(`[eventos] ranking falhou (${e instanceof Error ? e.message : e})`); + return []; + } +} + // ── Apresentação legível (linguagem pro cidadão comum) ─────────────────────── export const CATEGORIA_META: Record<