diff --git a/.gitignore b/.gitignore
index 5a44a8d..777dd93 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,6 @@ Thumbs.db
# Obsidian — vault local é camada de pensamento, não versionada (ver OBSIDIAN.md)
.obsidian/
+
+# Obsidian Bases (lixo local)
+*.base
diff --git a/.gitleaks.toml b/.gitleaks.toml
index 83ec59f..b1958ed 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -18,14 +18,19 @@ keywords = ["portal_transparencia_api_key"]
[[rules]]
id = "generic-env-secret"
-description = "Atribuição de segredo (api key, token, senha) com valor real"
-regex = '''(?i)\b(api[_-]?key|apikey|secret|token|password|passwd|senha|client[_-]?secret)\b\s*[:=]\s*['"]?[a-z0-9_\-./+=]{12,}['"]?'''
+description = "Atribuição de segredo (api key, token, senha) com valor literal entre aspas"
+# Exige ASPAS no valor: segredos hardcoded são strings literais ("abc123...").
+# Referências de código como `= process.env.OPENAI_API_KEY` (sem aspas) não casam.
+regex = '''(?i)\b(api[_-]?key|apikey|secret|token|password|passwd|senha|client[_-]?secret)\b\s*[:=]\s*['"][a-z0-9_\-./+=]{12,}['"]'''
keywords = ["api_key","apikey","secret","token","password","passwd","senha"]
# ── Allowlist: o que NÃO é vazamento (placeholders, exemplos, docs) ───────────
[allowlist]
-description = "Placeholders e arquivos de exemplo — não são segredos reais"
+description = "Placeholders, exemplos e referências de código — não são segredos reais"
+# Testa a LINHA inteira (não só o trecho casado): assim `process.env.X` e
+# placeholders em qualquer posição da linha são corretamente ignorados.
+regexTarget = "line"
paths = [
'''\.env\.example$''',
'''\.gitleaks\.toml$''',
@@ -34,5 +39,6 @@ paths = [
]
regexes = [
'''(?i)(your[_-]?|sua[_-]?|seu[_-]?|change[_-]?me|example|placeholder|dummy|fake|test|sample|xxx+|<[^>]+>|chave[_-]?aqui|cole[_-]?aqui|\.\.\.)''',
- '''(?i)process\.env\.''',
+ '''process\.env\.''',
+ '''import\.meta\.env\.''',
]
diff --git a/.planning/DESIGN_SYSTEM.md b/.planning/DESIGN_SYSTEM.md
index 62e601f..ec85554 100644
--- a/.planning/DESIGN_SYSTEM.md
+++ b/.planning/DESIGN_SYSTEM.md
@@ -36,6 +36,8 @@ Agentes devem consultar esses arquivos antes de alterar UI, componentes, navega
| stories | atualizações recentes |
| explorar | navegar por entidades |
+Reações estruturadas permitidas para objetos públicos: `apoio`, `parcial`, `contra`, `faltou informação`. Elas representam opinião do usuário e nunca podem aparecer como fato, fonte, evidência ou sinal de atenção.
+
## Estrutura de pastas recomendada
```text
@@ -185,6 +187,7 @@ Criar em `packages/ui/src/components/deolho`:
- `ContextualActionDrawer` no mobile;
- painel lateral de evidências no desktop, aberto quando o usuário seleciona evidência;
- `sonner` para feedback curto, sem tom sensacionalista.
+- ações sociais permitidas apenas em objetos públicos: acompanhar, reagir de forma estruturada e compartilhar; contribuição contextual curta exige moderação antes de publicação.
O shell não deve depender de dados reais. Usar fixtures sintéticas claramente marcadas até a ingestão existir.
@@ -292,6 +295,7 @@ Dado incompleto: bloco de limitação no card
- Limitações devem usar `BlocoLimitacaoDado`.
- Sinais de atenção devem usar `SinalAtencaoCard` e o aviso obrigatório.
- Não criar comentários livres, likes, ranking moral ou interface de denúncia sensacionalista.
+- Não gamificar reputação pública de usuário; hábitos e pontos devem ser privados e focados em leitura, evidência aberta e contribuição contextual útil.
- Todo componente cívico deve ter Storybook com estados: padrão, carregando, vazio, erro, dado incompleto e fonte atrasada quando fizer sentido.
## O que deve ir em `packages/ui`
diff --git a/.planning/HANDOFF.md b/.planning/HANDOFF.md
index 6a15182..5eab7ad 100644
--- a/.planning/HANDOFF.md
+++ b/.planning/HANDOFF.md
@@ -5,8 +5,8 @@ Ultima atualizacao: 2026-05-30
## Estado atual
- Repositorio local: `D:\DeOlho`
-- Branch atual: `codex/fundacao-civica-eventos`
-- Observacao Git: `git pull --ff-only` nao executa nesta branch porque ela ainda nao tem upstream configurado.
+- Branch atual: `codex/m0-fundacao-social-grafo`
+- Observacao Git: `git pull --ff-only` foi executado em `main` antes da branch e estava atualizado; a branch local ainda nao tem upstream configurado.
- Territorio da entrega: Americana/SP primeiro.
- Escopo trabalhado: fundacao civica com eventos, evidencias, vinculos documentados, fluxos financeiros e cobertura de fontes.
@@ -141,3 +141,201 @@ Fazer a fatia de produto "linha do tempo civica":
2. Tela de evento com fonte, trecho, data de publicacao/coleta, link original e limitacoes.
3. Filtros por rua/bairro/orgao/fornecedor/tipo de gasto.
4. Mapeadores dedicados da Camara para indicacoes/requerimentos de zeladoria.
+
+## Atualizacao M0: Fundacao Social-Civica + ADR do Grafo
+
+### Issue trabalhada
+
+M0: Fundacao Social-Civica + ADR do Grafo.
+
+### Entregue
+
+- Home reposicionada como radar social-civico com `EventoCivicoCard`, badges de tipo/fonte/confianca, limitacoes, evidencias, conexoes e contexto territorial Wikipedia com fallback.
+- Compositor renomeado para `evento-compositor`, com contrato `EventoComposto`, suporte a `fornecedorDocumento` e `credorDocumento`, avisos obrigatorios em sancoes e degradacao visivel quando falta fonte/evidencia.
+- Politica de Wikipedia isolada: enriquecimento permitido apenas para cidade, territorio, orgao, unidade de orgao e lugar publico.
+- Reacoes civicas atualizadas para enum fechado: `apoio`, `parcial`, `contra`, `faltou_informacao`; continuam opiniao de usuario, sem persistencia nesta entrega.
+- ADR criada em `.planning/research/GRAPH.md`: decisao recomendada e evolutiva, com PostgreSQL como fonte canonica/read model antes de qualquer Neo4j em producao.
+- `.planning` e `docs/` atualizados para guardrails de rede social civica, contribuicoes contextuais moderadas, habitos privados e mapa de conexoes documentadas.
+
+### Verificacoes executadas
+
+- `pnpm --filter web test:compositor` -> OK.
+- `pnpm -r typecheck` -> OK.
+- `pnpm -r lint` -> OK.
+- `pnpm -r build` -> OK.
+- `git diff --check` -> OK; apenas avisos CRLF do Windows.
+- Validacao visual com Playwright em `http://127.0.0.1:3000/` desktop e mobile: HTTP 200, 30 cards, fonte/confianca visiveis, sem linguagem publica de post/like/comentario livre, sem overflow horizontal raiz.
+
+### Pendencias conhecidas
+
+- `/explorar` ainda e a experiencia de descoberta atual; a substituicao por Mapa de conexoes fica para a proxima issue.
+- A pasta/import path `components/feed` permanece como legado tecnico; a UI e os comentarios de produto foram ajustados para radar/evento civico.
+- Sem Neo4j, comentarios persistentes, perfis, pontos ou gamificacao publica nesta entrega.
+
+### Proxima issue recomendada
+
+Criar `/explorar` como Mapa de conexoes com read model PostgreSQL:
+
+1. `getGraphSnapshot({ territory, rootEntityId, depth })`.
+2. Lista acessivel + grafo compacto com painel de evidencia.
+3. Limites iniciais: depth 1, maximo 150 nos e 300 arestas.
+4. Toda aresta publica precisa de fonte, confianca e evidencia; sinais neutros mostram aviso de que nao indicam irregularidade.
+
+## Atualizacao: Agente leve de apresentacao e entrada em eventos
+
+### Entregue
+
+- Criado agente leve em `packages/collectors/src/agents/civic-post-agent.ts`.
+ - Usa OpenAI Responses API com `gpt-4.1-nano` e JSON schema quando ha cota.
+ - Consome input compacto e `max_output_tokens` baixo.
+ - Nunca cria fato, acusacao, relacao ou valor fora dos campos recebidos.
+ - Se a API falhar ou faltar cota, cai em fallback deterministico.
+- Criado script `pnpm --filter @deolho/collectors format:posts -- --limit=N`.
+- Criado script `pnpm --filter @deolho/collectors sync:civic` para mapear PNCP, TCE, Diario e formatar eventos recentes no final.
+- Home agora ordena por `mais recentes` por padrao, com alternativas `em alta na semana` e `mais conectados`.
+- `/explorar` foi unificado com o Inicio: redireciona para `/?ordem=semana`.
+- Cards do radar agora levam para `/evento/[id]` via `ler tudo`.
+- Criada pagina `/evento/[id]` com cabecalho visual, imagem contextual, evidencia principal, conexoes documentadas quando existirem e relacionados com fallback por categoria/fonte.
+
+### Verificacoes adicionais
+
+- `pnpm --filter @deolho/collectors typecheck` -> OK.
+- `pnpm --filter @deolho/collectors format:posts -- --limit=5 --force` -> OK; atualizou 5 eventos recentes via fallback porque a API retornou `insufficient_quota`.
+- Playwright em mobile e desktop:
+ - Home HTTP 200;
+ - filtros de ordenacao presentes;
+ - primeiro card abre `/evento/[id]`;
+ - detalhe HTTP 200 com evidencia e relacionados;
+ - `/explorar` redireciona para `/?ordem=semana`;
+ - sem overflow horizontal raiz e sem erros de console.
+
+### Pendencia operacional
+
+- A chave OpenAI foi criada e salva em `.env.local`, mas a conta/projeto retornou `insufficient_quota`. O agente esta pronto; para usar a camada OpenAI, e necessario habilitar credito/cota no projeto da Platform.
+
+## Atualizacao: IA visivel, leitura completa e recorte 2026+
+
+### Entregue
+
+- O agente leve agora recebe trechos de `evidence` e produz `leituraCompleta`, alem de titulo, resumo, linha fina, etiquetas, conexoes textuais e aviso.
+- `format:posts` passou a processar por padrao apenas eventos desde `2026-01-01`; aceita `--since=YYYY-MM-DD` para mudar o recorte.
+- O metadata salvo em `entidades.apresentacao` agora registra `leituraCompleta`, `evidenciasLidas`, `desde`, modo/modelo, titulo/resumo original e data de geracao.
+- O compositor preserva titulo/resumo ja organizados pelo agente e separa conexoes textuais do agente como `tipo: "ia"`, com tooltip proprio.
+- Cards mostram selo visual quando o titulo/resumo foram organizados por IA/agente.
+- A pagina `/evento/[id]` usa `BlocoExplicacaoIA` para exibir claramente a leitura organizada, metadados do agente, fonte usada e limitacao de que a camada apenas organiza texto.
+- A pagina de detalhe ganhou bloco explicito de contexto visual Wikipedia com aviso de que nao e fonte do evento.
+
+### Verificacoes adicionais
+
+- `pnpm --filter @deolho/collectors typecheck` -> OK.
+- `pnpm --filter web test:compositor` -> OK.
+- `pnpm -r lint` -> OK.
+- `pnpm -r build` -> OK.
+- Browser local em `http://127.0.0.1:3000/` -> Home abre com ordenacao e contexto Wikipedia; como o Postgres local estava fora, validou estado vazio/fallback.
+
+### Pendencias operacionais
+
+- `pnpm --filter @deolho/collectors format:posts -- --limit=8 --force` nao conseguiu executar porque o Postgres local recusou conexao (`ECONNREFUSED`) e o Docker Desktop nao estava ativo.
+- Quando o banco voltar, rodar `pnpm --filter @deolho/collectors format:posts -- --limit=80 --force` para preencher a nova leitura completa nos eventos recentes.
+
+## Atualizacao: Wikipedia estrategica, agente local e datas do Diario
+
+### Entregue
+
+- O radar "mais recentes" agora ordena por `coalesce(data_evento, published_at::date)` e nao usa mais `criado_em` como data publica. Eventos sem data publica caem no fim e nao entram no filtro "em alta na semana".
+- O compositor mostra limitacao visivel quando evento nao tem data publica confiavel.
+- O mapper do Diario Oficial adiciona limitacao `data publica` quando o atomo nao tem `edicaoDate`; ao remapear, esses eventos deixam claro que nao devem ser tratados como recentes.
+- O agente de apresentacao ganhou provedores:
+ - `DEOLHO_POST_AGENT_PROVIDER=local` (padrao): deterministicamente local, zero tokens externos;
+ - `DEOLHO_POST_AGENT_PROVIDER=ollama`: LLM local via Ollama;
+ - `DEOLHO_POST_AGENT_PROVIDER=openai`: Responses API quando houver cota;
+ - `DEOLHO_POST_AGENT_PROVIDER=auto`: tenta Ollama e cai para local, sem usar token externo.
+- A leitura completa aumentou para ate 3600 caracteres e usa ate 5 evidencias/trechos para explicar o documento em secoes.
+- `/evento/[id]` agora sempre mostra uma leitura completa organizada: usa a apresentacao salva quando existir, ou gera leitura local ao vivo a partir das evidencias da pagina.
+- Wikipedia agora e contextual por evento: tenta orgaos, empresas, pessoas publicas, leis e conceitos publicos citados, alem do territorio.
+- Home passa a destacar contexto enciclopedico dos eventos recentes quando houver eventos, em vez de depender apenas de Americana.
+- A UI explicita que Wikipedia contextualiza, mas nao e fonte do evento; a prova continua nas evidencias oficiais.
+
+### Configuracao local recomendada
+
+Para zero tokens externos:
+
+```bash
+DEOLHO_POST_AGENT_PROVIDER=local
+```
+
+Para usar IA local com Ollama:
+
+```bash
+ollama pull llama3.2:3b
+DEOLHO_POST_AGENT_PROVIDER=ollama
+DEOLHO_POST_AGENT_LOCAL_MODEL=llama3.2:3b
+```
+
+## Atualizacao: ficha publica da evidencia e Wikipedia restrita ao evento
+
+### Entregue
+
+- Home deixou de renderizar bloco Wikipedia generico. O enriquecimento enciclopedico fica dentro da pagina do evento, onde ha contexto e evidencias.
+- Wikipedia por evento deixou de usar fallback automatico de Americana/SP e agora tenta apenas entidades, leis e conceitos diretamente citados no evento ou nas evidencias.
+- A pagina `/evento/[id]` ganhou ficha publica da evidencia antes da leitura organizada:
+ - o que aconteceu;
+ - por que/objeto;
+ - quem esta envolvido;
+ - quando aconteceu/publicou/coletou;
+ - onde aconteceu;
+ - o que foi publicamente divulgado;
+ - lacunas e limitacoes.
+- O read model de detalhe agora carrega territorio, datas da evidencia e metadados das relacoes documentadas para alimentar a ficha sem inferencia.
+- Conexoes documentadas mostram fonte, confianca e aviso neutro quando nao forem fato oficial.
+
+### Guardrail registrado
+
+Wikipedia contextualiza apenas o que foi citado no evento. Nao prova fatos, nao cria vinculos e nao deve aparecer como contexto aleatorio na Home.
+
+### Verificacoes adicionais
+
+- `pnpm --filter web test:compositor` -> OK.
+- `pnpm -r typecheck` -> OK.
+- `pnpm -r lint` -> OK.
+- `pnpm -r build` -> OK.
+- `git diff --check` -> OK; apenas avisos CRLF do Windows.
+- Browser local em `http://127.0.0.1:3000/`:
+ - Home com 129.736 eventos e sem bloco Wikipedia generico;
+ - primeiro evento abre `/evento/[id]`;
+ - detalhe mostra ficha publica da evidencia, leitura organizada, Wikipedia contextual do evento, trecho original e relacionados/conexoes;
+ - mobile 390x844 e desktop 1280x900 sem overflow horizontal e sem erros de console.
+
+## Atualizacao: resumo mensal do radar e cards mais explicativos
+
+### Entregue
+
+- `mais recentes` agora abre com uma leitura local do mês antes da lista normal:
+ - período, total de eventos, valor citado, fontes e categorias mais presentes;
+ - pontos úteis para entender o mês;
+ - recomendações de acontecimentos do próprio mês.
+- Criado read model `getResumoMensalRadar`, com fallback para o mês mais recente com dados quando o mês corrente não tiver eventos.
+- Criado componente `ResumoMensalRadarCard`.
+- Cards de evento ficaram mais densos e legíveis:
+ - mídia visual temática local por tipo de evento;
+ - título maior;
+ - descrição mais contextual;
+ - mini-ficha com `Para que`, `Envolvidos`, `Onde` e `Quando`.
+- O agente local de apresentação foi melhorado para contratos recentes:
+ - preserva fornecedor no título;
+ - corta campos colados por rótulos estruturados;
+ - usa evidência para objeto/finalidade;
+ - continua sem tokens externos por padrão.
+- Reprocessados 120 eventos recentes com `pnpm --filter @deolho/collectors format:posts -- --limit=120 --force`.
+
+### Verificacoes adicionais
+
+- `pnpm --filter web test:compositor` -> OK.
+- `pnpm --filter @deolho/collectors typecheck` -> OK.
+- `pnpm -r lint` -> OK.
+- `pnpm -r build` -> OK.
+- Browser local:
+ - resumo mensal visível em `mais recentes`;
+ - recomendações do mês visíveis;
+ - cards com mini-ficha e imagens locais;
+ - mobile 390x844 e desktop 1280x900 sem overflow horizontal e sem erros de console.
diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md
index 84f6861..3d6d829 100644
--- a/.planning/PROJECT.md
+++ b/.planning/PROJECT.md
@@ -51,9 +51,15 @@ Transformar um contrato público difícil de entender em uma página clara, rast
- [ ] **OSS-02**: Pipeline de CI/CD aberto e reproduzível
- [ ] **OSS-03**: Vault Obsidian público (via Obsidian Publish) com decisões, specs e contexto do projeto
+**Fundação Social-Cívica — v0**
+- [ ] **SOC-01**: Reações cívicas usam enum fechado (`apoio`, `parcial`, `contra`, `faltou informação`) e são sempre opinião de usuário, nunca fato oficial
+- [ ] **SOC-02**: Contribuições contextuais curtas entram com moderação antes de publicação
+- [ ] **SOC-03**: Acompanhar entidades e hábitos privados podem existir sem ranking público ou competição moral
+- [ ] **GRAPH-01**: Mapa de conexões usa apenas nós e arestas documentadas, cada aresta com fonte, evidência e confiança
+
### Out of Scope
-- **Feed social / comentários abertos** — risco de virar arena política antes de ter moderação adequada; entra em v2 com regras estruturadas
+- **Feed social / comentários abertos sem moderação** — risco de virar arena política; a direção permitida é social-cívica com reações estruturadas, contribuições contextuais moderadas e evidência obrigatória
- **Chat com IA como feature principal** — a IA serve para explicar e resumir, não para ser o produto em si
- **Dados em "tempo real"** — fontes públicas não atualizam instantaneamente; prometer isso é desonesto
- **Dados municipais genéricos (v1)** — Americana-SP é o piloto municipal; outras prefeituras entram depois de provar a tese com Americana
@@ -125,6 +131,8 @@ Transformar um contrato público difícil de entender em uma página clara, rast
| Público-alvo amplo desde v1 (jornalistas + cidadãos + devs) | Não sacrificar profundidade por simplicidade nem vice-versa — três perfis têm necessidades complementares | — Pending |
| Sem comentários abertos em v1 | Risco de desinformação e perseguição antes de moderação madura — comunidade estruturada vem depois | — Pending |
| Separar pessoa pública × cidadão comum desde a arquitetura | Obrigação jurídica e ética — não é feature opcional | — Pending |
+| Grafo de conexões começa como read model PostgreSQL | O banco já possui `entities` e `entity_relationships`; Neo4j só entra se consultas profundas ou analytics justificarem nova infraestrutura | — Proposed 2026-05-30 |
+| Social-cívico não é rede social aberta | Reações são estruturadas, comentários são contribuições moderadas e gamificação é privada para não incentivar acusação ou competição moral | — Proposed 2026-05-30 |
## Evolution
diff --git a/.planning/research/GRAPH.md b/.planning/research/GRAPH.md
new file mode 100644
index 0000000..d4b1730
--- /dev/null
+++ b/.planning/research/GRAPH.md
@@ -0,0 +1,146 @@
+# ADR: Grafo de Conexões Cívicas
+
+Data: 2026-05-30
+
+## Status
+
+Proposta para M0. Decisão recomendada: **manter PostgreSQL como fonte canônica e criar um read model de grafo antes de avaliar Neo4j em produção**.
+
+## Contexto
+
+O DeOlho já possui os blocos centrais de grafo:
+
+- nós canônicos em `entities`;
+- arestas documentadas em `entity_relationships`;
+- evidências em `evidence`;
+- eventos públicos em `civic_events`.
+
+O objetivo de produto é visualizar Estado, órgãos, empresas, agentes públicos, contratos, sanções e vínculos documentados como rede de conexões. O risco central é transformar proximidade visual em acusação. Toda aresta pública precisa carregar fonte, evidência, confiança e limitação.
+
+## Opções Avaliadas
+
+### Opção A: PostgreSQL + read model de grafo
+
+Usar o PostgreSQL atual como fonte de verdade e expor um contrato `GraphNode`/`GraphEdge` para a UI. Consultas de vizinhança usam joins e, quando necessário, CTEs recursivas. A visualização pode ser implementada no front com biblioteca dedicada depois da M0.
+
+Vantagens:
+
+- sem nova infraestrutura;
+- preserva a proveniência já modelada;
+- menor risco de divergência entre banco relacional e grafo;
+- suficiente para vizinhança de 1-2 saltos e exploração territorial inicial.
+
+Custos:
+
+- consultas profundas e algoritmos de grafo ficam menos naturais;
+- precisa cuidar de limites para não renderizar redes grandes demais.
+
+### Opção B: Neo4j paralelo
+
+Adicionar Neo4j como índice de grafo sincronizado a partir do PostgreSQL. Neo4j usa modelo de grafo de propriedades e Cypher, que é uma linguagem própria para padrões de nós e relacionamentos.
+
+Vantagens:
+
+- consultas de caminho e padrões complexos ficam mais diretas;
+- bom encaixe futuro para investigação de múltiplos saltos;
+- pode viabilizar analytics de rede mais sofisticado.
+
+Custos:
+
+- nova infraestrutura, backup e observabilidade;
+- risco de divergência com a fonte canônica;
+- precisa de pipeline de sincronização idempotente e auditável;
+- aumenta a superfície operacional antes de validar a experiência.
+
+### Opção C: Híbrido evolutivo
+
+Começar com read model em PostgreSQL e só promover Neo4j quando houver necessidade comprovada: latência ruim, consultas de caminhos complexos, analytics de rede recorrente ou volume que torne o read model insuficiente.
+
+Vantagens:
+
+- entrega UX de grafo rapidamente sem travar a arquitetura;
+- preserva a opção Neo4j com contrato de dados estável;
+- permite medir antes de adicionar custo.
+
+Custos:
+
+- exige disciplina para manter o contrato `GraphNode`/`GraphEdge` independente do backend;
+- pode haver retrabalho no índice se o produto exigir Neo4j depois.
+
+## Decisão
+
+Adotar a **Opção C**.
+
+M0 não adiciona Neo4j. A próxima entrega deve criar um endpoint/read model em PostgreSQL que produza:
+
+```ts
+type GraphNode = {
+ id: string;
+ tipo: "empresa" | "orgao" | "unidade_orgao" | "pessoa_publica" | "contrato" | "territorio" | "tema";
+ label: string;
+ href: string;
+ fonte?: { id: string; estado?: string };
+ confianca?: string;
+ media?: { imagemUrl?: string; alt?: string; credito?: string };
+};
+
+type GraphEdge = {
+ id: string;
+ from: string;
+ to: string;
+ tipo: string;
+ label: string;
+ fonte: { id: string; url?: string };
+ confianca: string;
+ evidenciaHref?: string;
+ pesoVisual?: number;
+ aviso?: "Sinal de atenção não indica irregularidade";
+};
+```
+
+## Regras de Produto
+
+- Aresta sem evidência não entra no grafo público.
+- Vínculo por sobrenome, proximidade visual ou inferência heurística não vira aresta pública.
+- Termos como nepotismo ou conflito de interesse só aparecem após regra objetiva documentada ou revisão manual. Antes disso, usar “vínculo documentado” ou “sinal de atenção”.
+- O grafo deve começar com 1 salto e expansão controlada. Não renderizar rede completa por padrão.
+- Cada edge precisa permitir voltar à fonte ou ao evento que a sustenta.
+
+## Wire Textual para `/explorar`
+
+```text
+[Header]
+Mapa de conexões documentadas
+Busca: empresa, órgão, agente público, contrato ou tema
+Aviso: conexões mostram vínculos documentados; não indicam irregularidade.
+
+[Filtros]
+Território | Tipo de nó | Tipo de vínculo | Fonte | Confiança
+
+[Visualização]
+Grafo compacto com nó raiz e vizinhos de 1 salto
+Cada aresta tem label curto e abre PainelEvidencia
+
+[Lista acessível]
+Órgão A -- fornecedor documentado --> Empresa B
+Fonte: PNCP | Confiança: Fonte oficial | Ver evidência
+
+[Estados]
+Sem relações: "Nenhum vínculo documentado encontrado para estes filtros."
+Fonte parcial: mostrar limitação antes do grafo.
+```
+
+## Próxima Issue Recomendada
+
+Criar `/explorar` como “Mapa de conexões” usando PostgreSQL:
+
+1. Read model `getGraphSnapshot({ territory, rootEntityId, depth })`.
+2. UI com lista + grafo compacto: nós, arestas, filtros por tipo e painel de evidência.
+3. Limites: depth inicial 1, máximo 150 nós e 300 arestas.
+4. Estado vazio explicando ausência de relações sem sugerir irregularidade.
+
+## Referências
+
+- Neo4j Cypher docs: https://neo4j.com/docs/cypher/
+- PostgreSQL CTE/recursive queries: https://www.postgresql.org/docs/17/queries-with.html
+- Cytoscape.js docs: https://js.cytoscape.org/
diff --git a/2026-05-30.md b/2026-05-30.md
new file mode 100644
index 0000000..e69de29
diff --git "a/Sem t\303\255tulo.canvas" "b/Sem t\303\255tulo.canvas"
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ "b/Sem t\303\255tulo.canvas"
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/apps/web/package.json b/apps/web/package.json
index ee775b8..8672cf4 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -6,7 +6,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint",
+ "test:compositor": "tsx src/lib/evento-compositor.test.ts"
},
"dependencies": {
"@deolho/db": "workspace:*",
@@ -30,6 +31,7 @@
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
+ "tsx": "^4.22.3",
"typescript": "^5"
}
}
diff --git a/apps/web/public/media/eventos/ato-normativo.png b/apps/web/public/media/eventos/ato-normativo.png
new file mode 100644
index 0000000..1c14d22
Binary files /dev/null and b/apps/web/public/media/eventos/ato-normativo.png differ
diff --git a/apps/web/public/media/eventos/audiencia.png b/apps/web/public/media/eventos/audiencia.png
new file mode 100644
index 0000000..e2c19bf
Binary files /dev/null and b/apps/web/public/media/eventos/audiencia.png differ
diff --git a/apps/web/public/media/eventos/cargo-publico.png b/apps/web/public/media/eventos/cargo-publico.png
new file mode 100644
index 0000000..bc9952f
Binary files /dev/null and b/apps/web/public/media/eventos/cargo-publico.png differ
diff --git a/apps/web/public/media/eventos/contratacao.png b/apps/web/public/media/eventos/contratacao.png
new file mode 100644
index 0000000..f0a49e5
Binary files /dev/null and b/apps/web/public/media/eventos/contratacao.png differ
diff --git a/apps/web/public/media/eventos/obra.png b/apps/web/public/media/eventos/obra.png
new file mode 100644
index 0000000..f2030f7
Binary files /dev/null and b/apps/web/public/media/eventos/obra.png differ
diff --git a/apps/web/public/media/eventos/pagamento.png b/apps/web/public/media/eventos/pagamento.png
new file mode 100644
index 0000000..34b6157
Binary files /dev/null and b/apps/web/public/media/eventos/pagamento.png differ
diff --git a/apps/web/public/media/eventos/receita.png b/apps/web/public/media/eventos/receita.png
new file mode 100644
index 0000000..ffc7010
Binary files /dev/null and b/apps/web/public/media/eventos/receita.png differ
diff --git a/apps/web/public/media/eventos/sancao.png b/apps/web/public/media/eventos/sancao.png
new file mode 100644
index 0000000..a3f9203
Binary files /dev/null and b/apps/web/public/media/eventos/sancao.png differ
diff --git a/apps/web/src/app/evento/[id]/page.tsx b/apps/web/src/app/evento/[id]/page.tsx
new file mode 100644
index 0000000..35c93e5
--- /dev/null
+++ b/apps/web/src/app/evento/[id]/page.tsx
@@ -0,0 +1,624 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import {
+ ArrowLeft,
+ BookOpen,
+ CalendarDays,
+ ClipboardList,
+ Database,
+ ExternalLink,
+ FileText,
+ HelpCircle,
+ Link2,
+ MapPin,
+ SearchCheck,
+ Users,
+} from "lucide-react";
+import { AppShell } from "@/components/app-shell/app-shell";
+import { TipoInformacaoBadge, FonteBadge, ConfiancaBadge } from "@/components/deolho/badges";
+import { BlocoExplicacaoIA, BlocoLimitacaoDado } from "@/components/deolho/blocos";
+import { EventoCivicoCard } from "@/components/feed/evento-civico-card";
+import { ReacaoCivica } from "@/components/feed/reacao-civica";
+import { SaveShare } from "@/components/feed/save-share";
+import { comporEvento, leituraLocalInterpretativa, type Conexao, type EventoComposto } from "@/lib/evento-compositor";
+import { dataExtensa, fonteLabel, getEventoDetalhe, type EventoDetalhe } from "@/lib/eventos";
+import { contextoWikipediaEvento } from "@/lib/wikipedia";
+
+interface PageProps {
+ params: Promise<{ id: string }>;
+}
+
+export default async function EventoPage({ params }: PageProps) {
+ const { id } = await params;
+ const detalhe = await getEventoDetalhe(id);
+ if (!detalhe) notFound();
+
+ const composto = comporEvento(detalhe.evento);
+ const evidenciaPrincipal = detalhe.evidencias[0];
+ const apresentacao = composto.apresentacaoIA;
+ const leituraTexto = apresentacao?.leituraCompleta
+ ?? leituraLocalInterpretativa(detalhe.evento);
+ const leituraModo = apresentacao?.modo ?? "local";
+ const contextosWiki = await contextoWikipediaEvento({
+ evento: detalhe.evento,
+ evidencias: detalhe.evidencias,
+ limite: 5,
+ });
+ const contextoHero = contextosWiki.find((item) => item.info.foto) ?? contextosWiki[0];
+ const dataLabel = dataExtensa(
+ detalhe.evento.dataEvento ?? detalhe.evento.publishedAt?.toISOString().slice(0, 10) ?? null,
+ );
+ const fichaPublica = montarFichaPublica(detalhe, composto);
+
+ return (
+
+
+
+ voltar ao radar
+
+
+
+
+
+
+
+
+
+
+
{composto.selo.emoji} {composto.selo.label}
+
{composto.titulo}
+ {composto.resumo && (
+
{composto.resumo}
+ )}
+ {composto.valorLabel && (
+
+ {composto.valorLabel}
+
+ )}
+
+
+
+
+ {composto.avisos.map((aviso) => (
+
+ {aviso}
+
+ ))}
+
+
+
+ {leituraTexto && (
+ Boolean(item))}
+ limitacoes={[
+ apresentacao
+ ? "Organiza texto e leitura; fatos, valores e vínculos continuam vindo da evidência oficial."
+ : "Leitura local ao vivo, sem tokens externos; fatos, valores e vínculos continuam vindo da evidência oficial.",
+ ]}
+ />
+ )}
+
+
+
+
+
+ Contexto enciclopédico · Wikipedia
+
+
+ Personalizado pelas entidades, leis e conceitos citados. Não é fonte do evento; a prova continua nas evidências oficiais.
+
+ Campos extraídos dos dados coletados, evidências e vínculos documentados. A ficha responde o que, por que, quem, quando e onde sem transformar conexão em acusação.
+
+
+
+
+ {ficha.itens.map((item) => (
+
+
+
+ {item.titulo}
+
+
{item.valor}
+ {item.detalhe &&
{item.detalhe}
}
+
+ ))}
+
+
+
+ );
+}
+
+function FichaIcon({ id }: { id: FichaItemId }) {
+ const className = "h-3.5 w-3.5";
+ if (id === "aconteceu") return ;
+ if (id === "porque") return ;
+ if (id === "envolvidos") return ;
+ if (id === "quando") return ;
+ if (id === "onde") return ;
+ if (id === "divulgado") return ;
+ return ;
+}
+
+function montarFichaPublica(detalhe: EventoDetalhe, composto: EventoComposto): FichaPublica {
+ const evento = detalhe.evento;
+ const textosEvidencia = detalhe.evidencias
+ .map((ev) => ev.trecho?.replace(/\s+/g, " ").trim())
+ .filter((trecho): trecho is string => Boolean(trecho));
+ const textoBase = [evento.titulo, evento.resumo, ...textosEvidencia].filter(Boolean).join("\n");
+ const envolvidos = listarEnvolvidos(detalhe, textoBase);
+ const quando = descreverQuando(detalhe);
+ const onde = descreverTerritorio(evento.territorio);
+ const porque = extrairMotivoOuObjeto(textoBase) ?? composto.resumo ?? "A fonte pública não separou justificativa ou objeto em campo próprio.";
+ const divulgado = detalhe.evidencias.length > 0
+ ? `${detalhe.evidencias.length} evidência(s) coletada(s) de ${fonteLabel(evento.sourceId)}.`
+ : "A fonte principal foi registrada, mas ainda não há evidência granular ligada ao evento.";
+
+ const itens: FichaPublicaItem[] = [
+ {
+ id: "aconteceu",
+ titulo: "O que aconteceu",
+ valor: composto.titulo,
+ detalhe: resumoCurto(composto.resumo ?? evento.resumo ?? textosEvidencia[0] ?? "Evento público registrado no banco canônico.", 280),
+ },
+ {
+ id: "porque",
+ titulo: "Por que / objeto",
+ valor: resumoCurto(porque, 220),
+ detalhe: "Campo organizado a partir do objeto, finalidade, ação orçamentária ou texto do documento quando a fonte não traz campo dedicado.",
+ },
+ {
+ id: "envolvidos",
+ titulo: "Quem está envolvido",
+ valor: envolvidos.length > 0 ? envolvidos.slice(0, 5).join(" · ") : "Envolvidos não destacados em campo estruturado.",
+ detalhe: envolvidos.length > 5 ? `Mais ${envolvidos.length - 5} menção(ões) aparecem nas evidências abaixo.` : undefined,
+ },
+ {
+ id: "quando",
+ titulo: "Quando aconteceu",
+ valor: quando.valor,
+ detalhe: quando.detalhe,
+ },
+ {
+ id: "onde",
+ titulo: "Onde aconteceu",
+ valor: onde.valor,
+ detalhe: onde.detalhe,
+ },
+ {
+ id: "divulgado",
+ titulo: "Base pública",
+ valor: divulgado,
+ detalhe: `Fonte principal: ${fonteLabel(evento.sourceId)}. Confiança: ${composto.confianca.replace(/_/g, " ")}.`,
+ },
+ ];
+
+ if (composto.valorLabel) {
+ itens.splice(1, 0, {
+ id: "valor",
+ titulo: "Valor citado",
+ valor: composto.valorLabel,
+ detalhe: "Valor publicado pela fonte; não é juízo de mérito nem sinal automático.",
+ });
+ }
+
+ return { itens };
+}
+
+function listarEnvolvidos(detalhe: EventoDetalhe, textoBase: string): string[] {
+ const nomes = new Set();
+ const entidades = (detalhe.evento.entidades ?? {}) as {
+ orgaoNome?: string | null;
+ orgaos?: Array<{ nome?: string | null; sigla?: string | null }>;
+ pessoas?: Array<{ nome?: string | null; papel?: string | null; cargo?: string | null }>;
+ };
+ const push = (valor: string | null | undefined) => {
+ const limpo = limparValorExtraido(valor);
+ if (limpo && limpo.length >= 3) nomes.add(limpo);
+ };
+
+ push(entidades.orgaoNome);
+ for (const orgao of entidades.orgaos ?? []) push(orgao.sigla ? `${orgao.nome} (${orgao.sigla})` : orgao.nome);
+ for (const pessoa of entidades.pessoas ?? []) {
+ if (!pessoa.nome) continue;
+ push([pessoa.nome, pessoa.cargo ?? pessoa.papel].filter(Boolean).join(" — "));
+ }
+ for (const relacao of detalhe.relacoes) {
+ push(relacao.fromLabel);
+ push(relacao.toLabel);
+ }
+ for (const label of ["Fornecedor", "Credor", "Contratado", "Contratada", "Empresa", "Órg[ãa]o", "Secretaria", "Quem"]) {
+ push(extrairCampo(textoBase, label));
+ }
+
+ return [...nomes].slice(0, 12);
+}
+
+function descreverQuando(detalhe: EventoDetalhe): { valor: string; detalhe?: string } {
+ const evento = detalhe.evento;
+ const partes = [
+ evento.dataEvento ? `data do evento: ${dataExtensa(evento.dataEvento)}` : null,
+ evento.publishedAt ? `publicado em ${dataHoraCurta(evento.publishedAt)}` : null,
+ evento.fetchedAt ? `coletado em ${dataHoraCurta(evento.fetchedAt)}` : null,
+ ].filter((item): item is string => Boolean(item));
+ const evidenciasComData = detalhe.evidencias
+ .flatMap((ev) => [ev.publishedAt, ev.fetchedAt])
+ .filter((data): data is Date => Boolean(data));
+ const primeiraDataEvidencia = evidenciasComData[0];
+ if (partes.length === 0 && primeiraDataEvidencia) {
+ return {
+ valor: `evidência datada em ${dataHoraCurta(primeiraDataEvidencia)}`,
+ detalhe: "O evento não trouxe data própria; a data veio da evidência coletada.",
+ };
+ }
+ if (partes.length === 0) {
+ return { valor: "Sem data pública confiável.", detalhe: "Este evento não deve ser tratado como recente enquanto a fonte não trouxer data." };
+ }
+ return {
+ valor: partes[0] ?? "Data pública registrada.",
+ detalhe: partes.slice(1).join(" · ") || undefined,
+ };
+}
+
+function descreverTerritorio(raw: unknown): { valor: string; detalhe?: string } {
+ if (!raw || typeof raw !== "object") return { valor: "Americana/SP", detalhe: "Território padrão da coleta local." };
+ const territorio = raw as {
+ municipio?: string;
+ uf?: string;
+ bairro?: string;
+ endereco?: string;
+ mencoes?: Array<{ tipo?: string; nome?: string }>;
+ };
+ const cidade = [territorio.municipio ?? "Americana", territorio.uf ?? "SP"].filter(Boolean).join("/");
+ const mencoes = (territorio.mencoes ?? [])
+ .map((m) => [m.tipo, m.nome].filter(Boolean).join(": "))
+ .filter(Boolean)
+ .slice(0, 4);
+ const detalhes = [territorio.endereco, territorio.bairro, ...mencoes].filter(Boolean);
+ return {
+ valor: cidade || "Americana/SP",
+ detalhe: detalhes.length > 0 ? detalhes.join(" · ") : "A fonte não trouxe rua, bairro ou equipamento público separado.",
+ };
+}
+
+function extrairMotivoOuObjeto(texto: string): string | null {
+ for (const label of ["Objeto", "Finalidade", "Justificativa", "Ação orçament[áa]ria", "Programa", "Descrição"]) {
+ const valor = extrairCampo(texto, label);
+ if (valor) return valor;
+ }
+ const primeiraFrase = texto.match(/[^.!?]+[.!?]?/)?.[0]?.trim();
+ return primeiraFrase && primeiraFrase.length > 20 ? primeiraFrase : null;
+}
+
+function extrairCampo(texto: string | null, label: string): string | null {
+ if (!texto) return null;
+ const re = new RegExp(`${label}\\s*:?\\s*([^\\n.]+?)(?:\\.\\s|\\.$|\\n|$)`, "i");
+ return limparValorExtraido(re.exec(texto)?.[1]);
+}
+
+function limparValorExtraido(valor: string | null | undefined): string | null {
+ if (!valor) return null;
+ const limpo = valor
+ .replace(/\s+/g, " ")
+ .replace(/\s+-\s+$/, "")
+ .trim();
+ if (!limpo || /^não informado$/i.test(limpo)) return null;
+ return resumoCurto(limpo, 120);
+}
+
+function resumoCurto(texto: string, max: number): string {
+ const limpo = texto.replace(/\s+/g, " ").trim();
+ return limpo.length > max ? `${limpo.slice(0, max - 1).trimEnd()}…` : limpo;
+}
+
+function formatarConfiancaRelacao(valor: string): string {
+ const numero = Number(valor);
+ if (!Number.isFinite(numero)) return valor || "não informada";
+ return `${Math.round(numero * 100)}% documentada`;
+}
+
+function EntidadeLink({ href, label }: { href: string | null; label: string }) {
+ if (!href) return {label};
+ return (
+
+ {label}
+
+ );
+}
+
+function dataHoraCurta(input: string | Date): string {
+ const data = input instanceof Date ? input : new Date(input);
+ if (Number.isNaN(data.getTime())) return String(input);
+ return data.toLocaleString("pt-BR", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function tituloLeituraAgente(modo?: string): string {
+ if (modo === "openai") return "Leitura completa organizada por IA";
+ if (modo === "ollama") return "Leitura completa organizada por IA local";
+ return "Leitura completa organizada pelo agente local";
+}
+
+function metaModoAgente(modo?: string, modelo?: string | null): string {
+ if (modo === "openai") return modelo ?? "OpenAI";
+ if (modo === "ollama") return modelo ? `Ollama · ${modelo}` : "Ollama local";
+ if (modo === "local") return "modo local sem tokens";
+ return "fallback local";
+}
+
+function ConexaoChip({ conexao }: { conexao: Conexao }) {
+ const emoji = iconeConexao(conexao.tipo);
+ const cls = "inline-flex items-center gap-1 rounded-full border border-foreground/15 bg-background/50 px-2.5 py-1 text-[11px] font-medium text-foreground/80 transition-colors hover:border-[var(--political)] hover:text-[var(--political)]";
+ if (!conexao.href) {
+ return (
+
+ {emoji} {conexao.label}
+
+ );
+ }
+ if (/^https?:\/\//i.test(conexao.href)) {
+ return (
+
+ {emoji} {conexao.label}
+
+ );
+ }
+ return (
+
+ {emoji} {conexao.label}
+
+ );
+}
+
+function iconeConexao(tipo: Conexao["tipo"]): string {
+ switch (tipo) {
+ case "empresa": return "🏢";
+ case "pessoa_publica": return "👤";
+ case "orgao": return "🏛️";
+ case "termo": return "📖";
+ case "evidencia": return "📄";
+ case "ia": return "✨";
+ default: return "•";
+ }
+}
+
+function labelTipoWiki(tipo: string): string {
+ const labels: Record = {
+ cidade: "cidade",
+ territorio: "território",
+ orgao: "instituição",
+ unidade_orgao: "órgão",
+ lugar_publico: "lugar",
+ empresa: "empresa",
+ pessoa_publica: "pessoa pública",
+ lei: "lei",
+ conceito_publico: "conceito",
+ };
+ return labels[tipo] ?? "contexto";
+}
diff --git a/apps/web/src/app/eventos/page.tsx b/apps/web/src/app/eventos/page.tsx
index 7dae23f..695ea06 100644
--- a/apps/web/src/app/eventos/page.tsx
+++ b/apps/web/src/app/eventos/page.tsx
@@ -1,6 +1,6 @@
/**
- * /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").
+ * /eventos foi unificado no Início (/). Esta rota só redireciona para evitar
+ * duas superfícies públicas concorrentes.
*/
import { redirect } from "next/navigation";
diff --git a/apps/web/src/app/explorar/page.tsx b/apps/web/src/app/explorar/page.tsx
index e395f9a..ccca7fb 100644
--- a/apps/web/src/app/explorar/page.tsx
+++ b/apps/web/src/app/explorar/page.tsx
@@ -1,236 +1,13 @@
-/**
- * /explorar — reformulado depois do feedback do Pedro.
- *
- * Saiu o grid genérico "tipos de entidade". Entra DESCOBERTA:
- * 1. Empresas em alta (CNPJs com mais menções no diário)
- * 2. Atos em destaque (átomos top-score da semana)
- * 3. Bairros de Americana (placeholder até dado real entrar)
- * 4. Vereadores e Escolas (placeholders honestos — exigem novas fontes)
- *
- * Mantém a vibe Instagram-explore mas focado em civic, não social.
- */
-import type { Metadata } from "next";
-import Link from "next/link";
-import { ArrowLeft, Building2, MapPin, GraduationCap, Users, Landmark } from "lucide-react";
-import { AppShell } from "@/components/app-shell/app-shell";
-import { AtomCard } from "@/components/feed/atom-card";
-import { EmptyState } from "@/components/feed/empty-state";
-import { GeoBreadcrumb } from "@/components/feed/geo-breadcrumb";
-import { getAtomsRanqueados, getTopCnpjs } from "@/lib/atoms";
-import { getPessoas, getOrgaos } from "@/lib/entidades";
-import { BAIRROS_AMERICANA } from "@/lib/civic-data";
+import { redirect } from "next/navigation";
-export const revalidate = 600;
-export const metadata: Metadata = {
- title: "Explorar — DeOlho",
- description: "Descobrir empresas em alta, atos em destaque, bairros e mais.",
-};
-
-function formatarCnpj(d: string): string {
- if (d.length !== 14) return d;
- return `${d.slice(0, 2)}.${d.slice(2, 5)}.${d.slice(5, 8)}/${d.slice(8, 12)}-${d.slice(12)}`;
+interface PageProps {
+ searchParams: Promise<{ cat?: string; ordem?: string }>;
}
-export default async function ExplorarPage() {
- const [topCnpjs, destaques, topPessoas, topOrgaos] = await Promise.all([
- getTopCnpjs(8),
- getAtomsRanqueados({ categoria: "tudo", limit: 3 }),
- getPessoas(6),
- getOrgaos(8),
- ]);
-
- return (
-
-
-
- voltar
-
-
-
-
Explorar
-
- Descubra empresas, atos em destaque, lugares e pessoas públicas.
-
-
-
-
- );
+export default async function ExplorarPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const params = new URLSearchParams();
+ params.set("ordem", sp.ordem === "recentes" ? "recentes" : "semana");
+ if (sp.cat) params.set("cat", sp.cat);
+ redirect(`/?${params.toString()}`);
}
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index 3a46bd9..bcfc1bd 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -1,21 +1,24 @@
/**
- * Início — o FEED CÍVICO de Americana, unificado.
+ * Início — radar social-cívico de Americana.
*
- * 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.
- *
- * Substitui as antigas superfícies separadas (início de átomos + /eventos): agora
- * é UM lugar só, alimentado automaticamente pelo que os coletores trazem.
+ * Cada acontecimento publico vem do banco canonico, com fonte, confianca,
+ * limitacoes e conexoes navegaveis. A camada social fica limitada a reacoes
+ * estruturadas, acompanhar e compartilhar objetos publicos.
*/
import { Eye } from "lucide-react";
import { AppShell } from "@/components/app-shell/app-shell";
-import { PostCard } from "@/components/feed/post-card";
+import { EventoCivicoCard } from "@/components/feed/evento-civico-card";
+import { ResumoMensalRadarCard } from "@/components/feed/resumo-mensal-radar";
import { StoriesRow, type StoryItem } from "@/components/feed/stories-row";
import { GeoBreadcrumb } from "@/components/feed/geo-breadcrumb";
import { EmptyState } from "@/components/feed/empty-state";
-import { getEventosRanqueados, getEventosStats, type CategoriaEvento } from "@/lib/eventos";
+import {
+ getEventosRanqueados,
+ getEventosStats,
+ getResumoMensalRadar,
+ type CategoriaEvento,
+ type OrdemEventos,
+} from "@/lib/eventos";
export const dynamic = "force-dynamic";
@@ -37,7 +40,7 @@ const CATS_VALIDAS: CategoriaEvento[] = [
];
interface PageProps {
- searchParams: Promise<{ cat?: string }>;
+ searchParams: Promise<{ cat?: string; ordem?: string }>;
}
export default async function HomePage({ searchParams }: PageProps) {
@@ -45,12 +48,20 @@ export default async function HomePage({ searchParams }: PageProps) {
const cat = (CATS_VALIDAS as readonly string[]).includes(sp.cat ?? "")
? (sp.cat as CategoriaEvento)
: undefined;
+ const ordem: OrdemEventos = sp.ordem === "semana" || sp.ordem === "relevantes"
+ ? sp.ordem
+ : "recentes";
- const [eventos, stats] = await Promise.all([
- getEventosRanqueados({ categoria: cat, limit: 30 }),
+ const [eventos, stats, resumoMensal] = await Promise.all([
+ getEventosRanqueados({ categoria: cat, limit: 30, ordem }),
getEventosStats(),
+ ordem === "recentes" ? getResumoMensalRadar({ categoria: cat }) : Promise.resolve(null),
]);
+ // Não repetir na lista os eventos já destacados em "Comece por estes" do resumo.
+ const idsNoResumo = new Set((resumoMensal?.recomendados ?? []).map((e) => e.id));
+ const eventosLista = eventos.filter((e) => !idsNoResumo.has(e.id));
+
const stories: StoryItem[] = [
{ 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" },
@@ -59,6 +70,12 @@ export default async function HomePage({ searchParams }: PageProps) {
{ 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" },
];
+ const ordemHref = (o: OrdemEventos) => `/${cat ? `?cat=${cat}&ordem=${o}` : o === "recentes" ? "" : `?ordem=${o}`}`;
+ const ordenacoes: Array<{ id: OrdemEventos; label: string }> = [
+ { id: "recentes", label: "mais recentes" },
+ { id: "semana", label: "em alta na semana" },
+ { id: "relevantes", label: "mais conectados" },
+ ];
return (
@@ -70,8 +87,8 @@ export default async function HomePage({ searchParams }: PageProps) {
{saudacao()} 👋
{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."}
+ ? `${stats.total.toLocaleString("pt-BR")} acontecimentos públicos. ${cat ? "Filtrando." : "Veja o que mudou na cidade."}`
+ : "Conecte o banco (DATABASE_URL) para o radar mostrar acontecimentos públicos."}
@@ -89,15 +106,38 @@ export default async function HomePage({ searchParams }: PageProps) {
+
+
+ {ordem === "recentes" && resumoMensal && eventos.length > 0 && (
+
+
+
+ )}
+
- {eventos.length === 0 ? (
+ {eventosLista.length === 0 ? (
) : (
- eventos.map((e) => )
+ eventosLista.map((e) => )
)}
diff --git a/apps/web/src/app/radar/page.tsx b/apps/web/src/app/radar/page.tsx
index 5649879..9c6ae7e 100644
--- a/apps/web/src/app/radar/page.tsx
+++ b/apps/web/src/app/radar/page.tsx
@@ -1,7 +1,7 @@
/**
- * /radar — feed completo de átomos (sem limite), ordenado por relevância.
+ * /radar — lista completa de átomos (sem limite), ordenada por relevância.
*
- * Stories são o único filtro — mesma estrutura que a Home.
+ * A trilha horizontal é o único filtro — mesma estrutura que a Home.
*/
import type { Metadata } from "next";
import Link from "next/link";
diff --git a/apps/web/src/app/ref/[slug]/page.tsx b/apps/web/src/app/ref/[slug]/page.tsx
index b944da1..33f71fb 100644
--- a/apps/web/src/app/ref/[slug]/page.tsx
+++ b/apps/web/src/app/ref/[slug]/page.tsx
@@ -1,7 +1,7 @@
/**
* /ref/[slug] — "tudo que se correlaciona com esta referência".
*
- * Quando um post no feed cita "Lei nº 6.274/2019", o usuário clica e
+ * Quando um evento público cita "Lei nº 6.274/2019", o usuário clica e
* cai aqui: a lista de todos os átomos (no diário inteiro) que tocam essa
* lei. Resposta direta ao pedido do Pedro: "clicar e ver tudo que
* correlaciona com essa informação, com resumo breve".
diff --git a/apps/web/src/components/app-shell/mobile-bottom-nav.tsx b/apps/web/src/components/app-shell/mobile-bottom-nav.tsx
index 1c0b5bb..73fbfd1 100644
--- a/apps/web/src/components/app-shell/mobile-bottom-nav.tsx
+++ b/apps/web/src/components/app-shell/mobile-bottom-nav.tsx
@@ -4,9 +4,8 @@
* MobileBottomNav — navegação inferior do mobile.
*
* Itens: Início · Radar · (+) · Explorar · Mapa
- * O botão "+" abre o ContextualActionDrawer (não é "criar post" — é ações
- * permitidas por rota: acompanhar, reportar erro, contribuir contexto,
- * exportar, citar página).
+ * Quando o botão "+" voltar, ele deve abrir ações permitidas por rota:
+ * acompanhar, reportar erro, contribuir contexto, exportar, citar página.
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
diff --git a/apps/web/src/components/app-shell/top-search-bar.tsx b/apps/web/src/components/app-shell/top-search-bar.tsx
index 7d67137..97bfce6 100644
--- a/apps/web/src/components/app-shell/top-search-bar.tsx
+++ b/apps/web/src/components/app-shell/top-search-bar.tsx
@@ -70,7 +70,7 @@ export function TopSearchBar() {