Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@deolho/db": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
Expand Down
180 changes: 180 additions & 0 deletions apps/web/src/app/eventos/page.tsx
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",
];
Comment on lines +27 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept every category emitted by the stats chips

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 to undefined, so the user gets the unfiltered timeline and the chip can never become active.

Useful? React with 👍 / 👎.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Show the required disclaimer for attention signals

When civic_events includes rows with trust_type = 'sinal_atencao' (for example the existing diário mapper writes limitacao_fonte events this way for unclassified publications), this card renders them just like official facts and never displays the required warning that an attention signal does not indicate irregularity. That makes the new public timeline violate the product rule for signals of attention in exactly the all-events view where those rows will appear.

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>
);
}
167 changes: 167 additions & 0 deletions apps/web/src/lib/eventos.ts
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",
});
}
17 changes: 17 additions & 0 deletions packages/db/src/index.ts
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";
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading