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
181 changes: 5 additions & 176 deletions apps/web/src/app/eventos/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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>
</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>
);
export default function EventosPage() {
redirect("/");
}
95 changes: 40 additions & 55 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
/**
* Homefeed cívico de Americana montado por ÁTOMOS reais.
* Inícioo 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();
Expand All @@ -30,47 +27,51 @@ 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 }>;
}

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 (
<AppShell>
{/* Saudação */}
<section className="pt-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Eye className="w-3.5 h-3.5 text-[var(--political)]" aria-hidden />
<span>de olho em americana</span>
</div>
<h1 className="text-2xl font-bold tracking-tight leading-tight mt-1">
{saudacao()} 👋
</h1>
<h1 className="text-2xl font-bold tracking-tight leading-tight mt-1">{saudacao()} 👋</h1>
<p className="text-sm text-foreground/70 mt-0.5">
{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."}
</p>
</section>

Expand All @@ -84,40 +85,24 @@ export default async function HomePage({ searchParams }: PageProps) {
/>
</div>

{/* Stories = único filtro */}
<section className="mt-4">
<StoriesRow items={stories} ariaLabel="Filtrar por categoria" />
<StoriesRow items={stories} ariaLabel="Filtrar por tipo de acontecimento" />
</section>

{/* Feed atômico ranqueado */}
<section className="mt-4 flex flex-col gap-4">
{atoms.length === 0 ? (
{eventos.length === 0 ? (
<EmptyState
icone="🪴"
titulo="Nenhum ato nessa categoria"
descricao="Volte mais tarde — o coletor roda periodicamente e novos atos aparecem aqui."
acao={
<Link href="/" className="text-sm font-semibold text-[var(--political)] mt-1">
voltar pra tudo
</Link>
}
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) => <AtomCard key={a.id} atom={a} />)
)}

{atoms.length >= 20 && (
<Link
href={cat === "tudo" ? "/radar" : `/radar?cat=${cat}`}
className="inline-flex items-center justify-center gap-1 h-11 rounded-full bg-foreground/5 text-foreground/80 font-medium hover:bg-foreground/10 transition-colors"
>
ver tudo no radar
</Link>
eventos.map((e) => <PostCard key={e.id} evento={e} />)
)}
</section>

<p className="text-[11px] text-muted-foreground/70 text-center mt-6 mb-2">
{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
</p>
</AppShell>
);
Expand Down
Loading
Loading