-
Notifications
You must be signed in to change notification settings - Fork 0
feat(web): linha do tempo cívica — torna os 130k civic_events visíveis #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| /** | ||
| * 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). | ||
| */ | ||
| 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"; | ||
|
|
||
| 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 ( | ||
| <AppShell> | ||
| <section className="pt-1"> | ||
| <p className="text-xs text-muted-foreground">linha do tempo cívica</p> | ||
| <h1 className="text-2xl font-bold tracking-tight leading-tight mt-1"> | ||
| O que aconteceu em Americana | ||
| </h1> | ||
| <p className="text-sm text-foreground/70 mt-0.5"> | ||
| {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."} | ||
| </p> | ||
| </section> | ||
|
|
||
| <div className="mt-3"> | ||
| <GeoBreadcrumb | ||
| niveis={[ | ||
| { label: "Brasil", href: "/explorar" }, | ||
| { label: "SP", href: "/explorar?uf=SP" }, | ||
| { label: "Americana", href: "/eventos", ativo: true }, | ||
| ]} | ||
| /> | ||
| </div> | ||
|
|
||
| {/* Filtros por categoria (com contagem real) */} | ||
| <nav className="mt-4 flex flex-wrap gap-2" aria-label="Filtrar por tipo de acontecimento"> | ||
| <FiltroChip label="tudo" emoji="✨" href="/eventos" ativo={!cat} /> | ||
| {stats.porCategoria.map((c) => { | ||
| const meta = categoriaMeta(c.categoria); | ||
| return ( | ||
| <FiltroChip | ||
| key={c.categoria} | ||
| label={`${meta.label} (${c.total.toLocaleString("pt-BR")})`} | ||
| emoji={meta.emoji} | ||
| href={`/eventos?cat=${c.categoria}`} | ||
| ativo={cat === c.categoria} | ||
| /> | ||
| ); | ||
| })} | ||
| </nav> | ||
|
|
||
| {/* Lista de eventos */} | ||
| <section className="mt-4 flex flex-col gap-3"> | ||
| {eventos.length === 0 ? ( | ||
| <EmptyState | ||
| icone="🪴" | ||
| titulo="Nada aqui ainda" | ||
| descricao="O coletor roda periodicamente. Se o banco estiver vazio, rode os mapeadores (map:pncp, map:tce, map:diario-atoms)." | ||
| /> | ||
| ) : ( | ||
| eventos.map((e) => <EventoCard key={e.id} evento={e} />) | ||
| )} | ||
| </section> | ||
| </AppShell> | ||
| ); | ||
| } | ||
|
|
||
| function FiltroChip({ | ||
| label, | ||
| emoji, | ||
| href, | ||
| ativo, | ||
| }: { | ||
| label: string; | ||
| emoji: string; | ||
| href: string; | ||
| ativo: boolean; | ||
| }) { | ||
| return ( | ||
| <Link | ||
| href={href} | ||
| className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-semibold transition-colors ${ | ||
| ativo | ||
| ? "border-[var(--political)] bg-[var(--political)]/10 text-[var(--political)]" | ||
| : "border-foreground/15 text-foreground/70 hover:bg-foreground/5" | ||
| }`} | ||
| > | ||
| <span aria-hidden>{emoji}</span> | ||
| {label} | ||
| </Link> | ||
| ); | ||
| } | ||
|
|
||
| function EventoCard({ evento }: { evento: Awaited<ReturnType<typeof getEventos>>[number] }) { | ||
| const meta = categoriaMeta(evento.categoria); | ||
| const valor = valorBRL(evento.valor); | ||
|
|
||
| return ( | ||
| <article className="rounded-xl border border-foreground/10 bg-card p-4"> | ||
| <header className="flex items-center justify-between gap-2"> | ||
| <span className="inline-flex items-center gap-1.5 text-xs font-semibold text-foreground/60"> | ||
| <span aria-hidden>{meta.emoji}</span> | ||
| {meta.label} | ||
| </span> | ||
| <span className="text-[11px] text-muted-foreground">{dataExtensa(evento.dataEvento)}</span> | ||
|
Comment on lines
+140
to
+147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| </header> | ||
|
|
||
| <h2 className="mt-1.5 text-[15px] font-semibold leading-snug text-foreground"> | ||
| {evento.titulo} | ||
| </h2> | ||
|
|
||
| {valor && ( | ||
| <p className="mt-1 text-lg font-bold tracking-tight text-foreground">{valor}</p> | ||
| )} | ||
|
|
||
| {evento.resumo && ( | ||
| <p className="mt-1 line-clamp-2 text-sm text-foreground/70">{evento.resumo}</p> | ||
| )} | ||
|
|
||
| <footer className="mt-3 flex items-center justify-between gap-2 border-t border-foreground/5 pt-2.5"> | ||
| <span className="text-[11px] text-muted-foreground"> | ||
| Fonte: {fonteLabel(evento.sourceId)} | ||
| </span> | ||
| {evento.sourceUrl && ( | ||
| <a | ||
| href={evento.sourceUrl} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="inline-flex items-center gap-1 text-[11px] font-semibold text-[var(--source)] hover:underline" | ||
| > | ||
| ver documento | ||
| <ExternalLink className="h-3 w-3" aria-hidden /> | ||
| </a> | ||
| )} | ||
| </footer> | ||
| </article> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| /** | ||
| * Read model da LINHA DO TEMPO CÍVICA — lê os eventos reais do banco canônico | ||
| * (civic_events) populado pelos coletores (PNCP, TCE-SP, Diário, CEIS…). | ||
| * | ||
| * É a ponte que torna VISÍVEL a fundação cívica: ~130 mil acontecimentos | ||
| * verificáveis de Americana, cada um com fonte, data e (quando há) valor. | ||
| * | ||
| * Resiliente: se DATABASE_URL não estiver definida (ex.: build no CI sem banco), | ||
| * degrada para vazio em vez de quebrar — mesma filosofia do loader de átomos. | ||
| */ | ||
| import "server-only"; | ||
| import { createDb, schema, type DB, desc, eq, sql } from "@deolho/db"; | ||
|
|
||
| const { civicEvents } = schema; | ||
|
|
||
| export type CategoriaEvento = | ||
| | "pagamento" | ||
| | "receita" | ||
| | "contratacao" | ||
| | "ato_normativo" | ||
| | "nomeacao_exoneracao" | ||
| | "sancao" | ||
| | "audiencia_conselho" | ||
| | "obra_zeladoria"; | ||
|
|
||
| export interface EventoCivico { | ||
| id: string; | ||
| categoria: string; | ||
| tipo: string; | ||
| titulo: string; | ||
| resumo: string | null; | ||
| dataEvento: string | null; | ||
| valor: string | null; | ||
| sourceId: string; | ||
| sourceUrl: string | null; | ||
| publishedAt: Date | null; | ||
| fetchedAt: Date | null; | ||
| limitacoes: unknown; | ||
| trustType: string; | ||
| } | ||
|
|
||
| let _db: DB | null | undefined; | ||
|
|
||
| /** Conexão lazy. null se não houver DATABASE_URL (degrada para vazio). */ | ||
| function getDb(): DB | null { | ||
| if (_db !== undefined) return _db; | ||
| const url = process.env.DATABASE_URL; | ||
| _db = url ? createDb(url, { max: 3 }) : null; | ||
| return _db; | ||
| } | ||
|
|
||
| export interface FiltroEventos { | ||
| categoria?: CategoriaEvento; | ||
| limit?: number; | ||
| offset?: number; | ||
| } | ||
|
|
||
| /** Eventos mais recentes primeiro (sem data caem no fim). */ | ||
| export async function getEventos(filtro: FiltroEventos = {}): Promise<EventoCivico[]> { | ||
| const db = getDb(); | ||
| if (!db) return []; | ||
| const { categoria, limit = 50, offset = 0 } = filtro; | ||
| try { | ||
| const rows = await db | ||
| .select({ | ||
| id: civicEvents.id, | ||
| categoria: civicEvents.categoria, | ||
| tipo: civicEvents.tipo, | ||
| titulo: civicEvents.titulo, | ||
| resumo: civicEvents.resumo, | ||
| dataEvento: civicEvents.dataEvento, | ||
| valor: civicEvents.valor, | ||
| sourceId: civicEvents.sourceId, | ||
| sourceUrl: civicEvents.sourceUrl, | ||
| publishedAt: civicEvents.publishedAt, | ||
| fetchedAt: civicEvents.fetchedAt, | ||
| limitacoes: civicEvents.limitacoes, | ||
| trustType: civicEvents.trustType, | ||
| }) | ||
| .from(civicEvents) | ||
| .where(categoria ? eq(civicEvents.categoria, categoria) : undefined) | ||
| .orderBy(sql`${civicEvents.dataEvento} desc nulls last`, desc(civicEvents.criadoEm)) | ||
| .limit(Math.min(limit, 200)) | ||
| .offset(offset); | ||
| return rows as EventoCivico[]; | ||
| } catch (e) { | ||
| console.warn(`[eventos] query falhou (${e instanceof Error ? e.message : e}) — degradando para vazio`); | ||
| return []; | ||
| } | ||
| } | ||
|
|
||
| export interface EstatEventos { | ||
| total: number; | ||
| porCategoria: Array<{ categoria: string; total: number }>; | ||
| } | ||
|
|
||
| /** Contagem total + por categoria, para o cabeçalho e os filtros. */ | ||
| export async function getEventosStats(): Promise<EstatEventos> { | ||
| const db = getDb(); | ||
| if (!db) return { total: 0, porCategoria: [] }; | ||
| try { | ||
| const rows = await db | ||
| .select({ categoria: civicEvents.categoria, total: sql<number>`count(*)::int` }) | ||
| .from(civicEvents) | ||
| .groupBy(civicEvents.categoria) | ||
| .orderBy(sql`count(*) desc`); | ||
| const total = rows.reduce((acc, r) => acc + Number(r.total), 0); | ||
| return { total, porCategoria: rows.map((r) => ({ categoria: r.categoria, total: Number(r.total) })) }; | ||
| } catch (e) { | ||
| console.warn(`[eventos] stats falhou (${e instanceof Error ? e.message : e})`); | ||
| return { total: 0, porCategoria: [] }; | ||
| } | ||
| } | ||
|
|
||
| // ── Apresentação legível (linguagem pro cidadão comum) ─────────────────────── | ||
|
|
||
| export const CATEGORIA_META: Record< | ||
| string, | ||
| { label: string; emoji: string; frase: string } | ||
| > = { | ||
| pagamento: { label: "Pagamento", emoji: "💸", frase: "a prefeitura pagou" }, | ||
| receita: { label: "Receita", emoji: "🪙", frase: "a prefeitura recebeu" }, | ||
| contratacao: { label: "Contrato", emoji: "📋", frase: "a prefeitura contratou" }, | ||
| ato_normativo: { label: "Ato oficial", emoji: "📜", frase: "foi publicado no diário" }, | ||
| nomeacao_exoneracao: { label: "Nomeação/Exoneração", emoji: "👤", frase: "mudança de cargo público" }, | ||
| sancao: { label: "Sanção", emoji: "⚠️", frase: "empresa com sanção registrada" }, | ||
| audiencia_conselho: { label: "Audiência/Conselho", emoji: "🏛️", frase: "reunião pública" }, | ||
| obra_zeladoria: { label: "Obra/Zeladoria", emoji: "🚧", frase: "obra ou serviço urbano" }, | ||
| }; | ||
|
|
||
| export function categoriaMeta(cat: string) { | ||
| return CATEGORIA_META[cat] ?? { label: cat, emoji: "•", frase: "" }; | ||
| } | ||
|
|
||
| /** Fonte em linguagem humana. */ | ||
| export function fonteLabel(sourceId: string): string { | ||
| const map: Record<string, string> = { | ||
| pncp: "Portal Nacional de Contratações (PNCP)", | ||
| "tce-sp": "Tribunal de Contas do Estado (TCE-SP)", | ||
| "diario-americana": "Diário Oficial de Americana", | ||
| "querido-diario": "Querido Diário", | ||
| "cgu-transparencia": "Portal da Transparência (CGU)", | ||
| "transparencia-americana": "Transparência Americana", | ||
| "camara-americana": "Câmara Municipal de Americana", | ||
| "receita-cnpj": "Receita Federal (CNPJ)", | ||
| tse: "Justiça Eleitoral (TSE)", | ||
| }; | ||
| return map[sourceId] ?? sourceId; | ||
| } | ||
|
|
||
| /** "R$ 56.424,00" ou null se sem valor. */ | ||
| export function valorBRL(valor: string | null): string | null { | ||
| if (!valor) return null; | ||
| const n = Number(valor); | ||
| if (Number.isNaN(n) || n === 0) return null; | ||
| return n.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }); | ||
| } | ||
|
|
||
| /** "19 de maio de 2026" ou "sem data". */ | ||
| export function dataExtensa(iso: string | null): string { | ||
| if (!iso) return "sem data"; | ||
| return new Date(iso + "T12:00:00").toLocaleDateString("pt-BR", { | ||
| day: "2-digit", | ||
| month: "long", | ||
| year: "numeric", | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,19 @@ | ||
| export { createDb, schema, type DB } from "./client"; | ||
| export * from "./schema/index"; | ||
|
|
||
| // Reexporta os operadores de query do Drizzle para os consumidores (app web, | ||
| // workers) não precisarem declarar drizzle-orm como dependência direta — o | ||
| // pacote @deolho/db encapsula o acesso ao banco. | ||
| export { | ||
| and, | ||
| asc, | ||
| count, | ||
| desc, | ||
| eq, | ||
| gte, | ||
| ilike, | ||
| inArray, | ||
| lte, | ||
| or, | ||
| sql, | ||
| } from "drizzle-orm"; |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The filter UI renders one chip for every category returned by
getEventosStats(), but this whitelist rejects several valid database enum values (limitacao_fonte,proposta_legislativa,indicacao_requerimento,relacionamento). If any of those rows exist, the generated chip links to/eventos?cat=..., then this validation drops the category toundefined, so the user gets the unfiltered timeline and the chip can never become active.Useful? React with 👍 / 👎.