---
id: MOD-PUBLICACOES-AGENDA-PORTAL
tipo: modulo
onda: 4
status: concluido
unidades: [MOD-PUBLICACOES-AGENDA]
fontes:
  - V2/Modules/Publicacoes/Agenda/
depende_de: [UI-PORTAL, SVC-SEO, SVC-MEDIA, SVC-GALLERY, CORE-RUNTIME]
consumidores: [UI-PORTAL, SVC-SEO, MOD-LAYOUTS-HOMEPAGE]
criticidade: media
dono: Agenda
---

# Agenda — páginas do portal

> `/agenda` (listagem com filtros e busca) e `/agenda/{slug}` (detalhe): consultas,
> estrutura, SEO/JSON-LD, mídia responsiva, cache, acessibilidade e o JS de busca na
> programação. Regras de negócio em [`spec.md`](spec.md); HTTP em [`rotas.md`](rotas.md).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Publica a agenda filtrável por dia/semana/mês e a página de cada evento publicado, com JSON-LD `Event`. |
| Entrada | `GET /agenda` (`date`, `week`, `month`, `search`, `page`), `GET /agenda/{slug}`. |
| Saída | HTML cacheado (`page.cache`), cards, tabela de programação, JSON-LD, meta tags. |
| Efeitos colaterais | `SEOService` registra acesso (`trackAccess`), cache Redis 5 min, `Cache-Control` 60 s, nenhuma escrita em banco. |
| Particularidade central | Listagem e detalhe têm regras de visibilidade diferentes (`published+active` vs `published`) e o layout é deliberadamente diferente (sem header/footer na listagem). |
| Como se integra | `v2-portal::app-layout`; componentes `v2-service-media::components.picture`; composer de SEO injeta `$seoTitle`/`$seoDescription`. |
| Se removido, quebra | Menu do portal, sitemap (URLs continuam geradas), feed RSS e cards da home apontam para 404; a home perde a seção de eventos. |
| Superfície pública | 2 rotas, 2 views, parâmetros de query, contrato de SEO e cache. |

## 1. Efeito

### 1.1 O que o usuário consegue fazer

- Ver a agenda futura em cards de 2 colunas, 12 por página.
- Alternar entre `Todos`, `Hoje`, `Esta semana` e `Próximo mês`.
- Buscar por título/local/organizador dentro do recorte atual.
- Abrir o detalhe com capa, data/hora, local, organizador, descrição, tópicos, público-alvo e programação.
- Filtrar a programação no cliente, sem recarregar.

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| HTML cacheado por URL (query incluída) por 5 min | `page.cache` | `V2/Core/Middleware/PageCacheMiddleware.php:25-66` |
| `Cache-Control: public, max-age=60, must-revalidate` | `public.cache` | `V2/Core/Middleware/PublicCacheMiddleware.php:86-91` |
| `X-Cache-Status: HIT|MISS` | `page.cache` | `PageCacheMiddleware.php:39,58` |
| Título/description/keywords no `SEOService` | `EventosPortalController@index` | `EventosPortalController.php:80-91` |
| JSON-LD `CollectionPage`/`Event` | views | `portal/index.blade.php:20-28`; `portal/show.blade.php:18-37` |
| Registro de acesso para ranking | composer do SEO | `V2/Services/SEO/Providers/SEOServiceProvider.php:30-36` |
| Flash `Filtro inválido...` (não renderizado) | controller | `EventosPortalController.php:56-59` |

### 1.3 Contrato observável (entrada → saída)

| Entrada | Saída |
|---|---|
| `/agenda` | `200`; `h1 Agenda de Eventos`; cards futuros; `CollectionPage` JSON-LD. |
| `/agenda?date=YYYY-MM-DD` | cards do dia (inclusive passados e contínuos); badge `dd/mm/aaaa`. |
| `/agenda?week=YYYY-MM-DD` | cards de domingo a sábado; badge `dd/mm a dd/mm/aaaa`. |
| `/agenda?month=YYYY-MM` | cards do mês; badge `Month/YYYY` em inglês. |
| `/agenda?search=x` | recorte futuro + `ilike` em 3 colunas. |
| `/agenda?date=&week=&month=` | futuros; nenhum aviso visível. |
| `/agenda/{slug}` publicado (mesmo inativo) | `200` + `Event` JSON-LD. |
| `/agenda/{slug}` rascunho/inexistente | `404`. |
| Qualquer combinação sem resultados | bloco `Nenhum evento encontrado` + `Limpar filtros` (se filtrado) ou `Em breve divulgaremos nossa agenda de eventos.` |

### 1.4 O que estas páginas NÃO fazem

- Não exibem eventos passados por padrão (só via filtro explícito).
- Não aplicam `is_active` no detalhe.
- Não têm paginação infinita, filtro por local/organizador, ordenação ou RSS próprio.
- Não exibem "evento encerrado", contagem regressiva, mapa, inscrição ou compartilhamento.
- Não emitem `robots` meta nem canonical com query preservada.
- Não usam imagem do evento como OG image (ver §9).
- Não renderizam `flash` de nenhum tipo.

## 2. Abordagem

### 2.1 Estratégia em uma página

Duas views independentes: a listagem é uma página "seca" (sem header/footer) montada com hero + busca + pills + grid; o detalhe usa o layout completo com acessibilidade e breadcrumb microdata. O controller só prepara dados/SEO; o layout `app-layout` repassa `pageTitle/pageDescription/coverImage/schemas` ao `base`, que inclui `metadata.seo` — onde o composer sobrescreve com o estado do `SEOService`.

```mermaid
flowchart LR
  R[GET /agenda] --> PC[page.cache]
  PC -->|MISS| C[EventosPortalController]
  C --> Q[Evento::published()->active()]
  Q --> F{filtro}
  F -->|date/week/month| S[scopes de calendário]
  F -->|nenhum| U[upcoming()]
  S --> O[upcomingOrdered + with image + paginate 12]
  U --> O
  O --> V[v2-agenda::portal.index]
  V --> L[v2-portal::app-layout -> metadata.seo]
  L --> SEO[SEOService title/description/keywords]
```

### 2.2 Mecânica passo a passo

1. `page.cache` verifica GET não-AJAX e caminho fora de `cms*/api*`; chave `page:sha1(fullUrl)` (`PageCacheMiddleware.php:16-33,68-90`).
2. Em MISS, `EventosPortalController@index` lê `date/week/month/search` (`:35-38`).
3. Base `published()->active()` (`:40`).
4. Precedência de filtros com validação `Carbon::hasFormat` (`:45-60`).
5. Busca opcional `ilike` (`:63-68`).
6. `upcomingOrdered()->with('image')->paginate(12)` (`:71`).
7. SEO: título por filtro, description/keywords fixos (`:80-91`); `filterLabel` (`:94-101`).
8. View monta hero, busca, pills (URLs geradas com `route()`), badge, grid e paginação (`index.blade.php:39-172`).
9. `app-layout` recebe `pageTitle`, `schemas`, flags de header/footer (`index.blade.php:14-37`).
10. `metadata.seo` usa `$seoTitle ?? ...` do composer; `og:image` vem de `$seo->getCoverImage()` (`metadata/seo.blade.php:1-27`).
11. `public.cache` adiciona headers se o controller não os definiu (`PublicCacheMiddleware.php:74-98`).
12. No detalhe: `where(slug)->published()->firstOrFail()` → `bindModel` → decode de tópicos/schedule → `renderer->toHtml` → view (`:110-139`).

### 2.3 Decisões de design e por quê

- **Listagem sem chrome do site** `[C]`: `showHeader/showFooter/showAccessibilityBar = false` (`index.blade.php:29-36`); a página fica autocontida.
- **Filtros como links (GET)** `[C]`: pills são `<a>` com `route(..., ['date' => ...])`, o que torna cada recorte cacheável e compartilhável (`index.blade.php:73-90`).
- **Busca no mesmo form GET** `[C]`: submit tradicional; o termo é preservado no input.
- **"Hoje" e "Esta semana" calculados na view** `[C]`: `$today` e `$thisWeek` usam `now()` no fuso da aplicação (`index.blade.php:7-11`).
- **Detalhe com `active()` ausente** `[C]`: mantém URLs antigas vivas após desativação.
- **JSON-LD no array do layout** `[C]`: o `Event` é montado na view (não no controller) e impresso pelo fallback `$schemas` do `metadata.seo` (`metadata/seo.blade.php:45-50`).
- **Busca interna 100% client-side** `[C]`: nenhuma rota/índice; percorre `textContent` das linhas (`show.blade.php:375-412`).

### 2.4 Trechos de código pivô

```blade
{{-- V2/Modules/Publicacoes/Agenda/Views/portal/index.blade.php:20-37 — schema e flags de layout --}}
'schemas'               => [
    [
        '@context' => 'https://schema.org',
        '@type' => 'CollectionPage',
        'name' => 'Agenda de Eventos',
        'description' => 'Confira a agenda de eventos, cursos e programações.',
        'url' => route('v2.agenda.portal.index', [], false)
    ]
],
'fullWidthMain'         => true,
'showHeader'            => false,
'showMainHeader'        => false,
'showMainFooter'        => false,
'showFooter'            => false,
'showAccessibilityBar'  => false,
'showMainLeftArea'      => false,
'showMainRightArea'     => false,
])
```

A listagem não tem menu, rodapé nem barra de acessibilidade; o único caminho de volta é o breadcrumb do hero `[C]`.

```blade
{{-- V2/Modules/Publicacoes/Agenda/Views/portal/index.blade.php:73-90 — pills de filtro por URL --}}
<div class="d-flex flex-wrap justify-content-center gap-2 mt-4">
    <a href="{{ route('v2.agenda.portal.index') }}"
       class="btn btn-sm @if(!$isFiltered) btn-primary @else btn-outline-primary @endif rounded-pill">
        Todos
    </a>
    <a href="{{ route('v2.agenda.portal.index', ['date' => $today]) }}"
       class="btn btn-sm @if($filterDate === $today) btn-primary @else btn-outline-primary @endif rounded-pill">
        Hoje
    </a>
    <a href="{{ route('v2.agenda.portal.index', ['week' => $thisWeek]) }}"
       class="btn btn-sm @if($filterWeek === $thisWeek) btn-primary @else btn-outline-primary @endif rounded-pill">
        Esta semana
    </a>
    <a href="{{ route('v2.agenda.portal.index', ['month' => $nextMonth]) }}"
       class="btn btn-sm @if($filterMonth === $nextMonth) btn-primary @else btn-outline-primary @endif rounded-pill">
        Próximo mês
    </a>
</div>
```

`$isFiltered` inclui `search`, então buscar sem data deixa os quatro pills em `outline` (nenhum ativo) `[C]` (`:7`).

```blade
{{-- V2/Modules/Publicacoes/Agenda/Views/portal/index.blade.php:113-124 — mídia responsiva e fallback --}}
<div class="news-thumb">
    @if($evento->getImageUrl())
        @include('v2-service-media::components.picture', [
            'originalPath' => $evento->getRelationValue('image')->file_path,
            'variants' => $evento->getImageVariants(),
            'alt' => $evento->title,
            'class' => 'img-fluid',
            'lazy' => true,
        ])
    @else
        <img src="{{ asset('assets/sucesu/img/event-default.svg') }}" alt="{{ $evento->title }}" class="img-fluid" loading="lazy" decoding="async">
    @endif
</div>
```

O `alt` é o título (não o `alt_text` da galeria); `picture` gera `<source>` por largura e `<img>` com `loading="lazy"` (`V2/Services/Media/Views/components/picture.blade.php:8-16,52-66`) `[C]`.

```php
// V2/Modules/Publicacoes/Agenda/Controllers/EventosPortalController.php:80-91 — SEO da listagem por filtro
if ($activeFilter === 'date') {
    $this->seo->setTitle('Eventos em ' . \Carbon\Carbon::parse($filterDate)->format('d/m/Y') . ' — ' . config('app.name'));
} elseif ($activeFilter === 'week') {
    $this->seo->setTitle('Eventos de ' . $weekStart->format('d/m') . ' a ' . $weekEnd->format('d/m/Y') . ' — ' . config('app.name'));
} elseif ($activeFilter === 'month') {
    $this->seo->setTitle('Eventos de ' . \Carbon\Carbon::parse($filterMonth . '-01')->format('F/Y') . ' — ' . config('app.name'));
} else {
    $this->seo->setTitle('Agenda de Eventos — ' . config('app.name'));
}
$this->seo->setDescription('Confira a agenda de eventos, cursos e programações da SUCESU.');
$this->seo->setKeywords('agenda, eventos, cursos, programação, ' . config('app.name'));
```

`format('F/Y')` usa o inglês do Carbon (locale pt-BR do app não afeta `format`), produzindo `November/2026` `[C]`.

```blade
{{-- V2/Modules/Publicacoes/Agenda/Views/portal/show.blade.php:18-37 — JSON-LD Event --}}
'schemas'               => [
    [
        '@context' => 'https://schema.org',
        '@type' => 'Event',
        'name' => $evento->title,
        'description' => $metaDescription,
        'image' => $metaImage,
        'startDate' => $evento->event_date_start->toIso8601String(),
        'endDate' => $evento->event_date_end ? $evento->event_date_end->toIso8601String() : $evento->event_date_start->toIso8601String(),
        'location' => $evento->location ? [
            '@type' => 'Place',
            'name' => $evento->location,
        ] : null,
        'organizer' => $evento->organizer ? [
            '@type' => 'Organization',
            'name' => $evento->organizer,
        ] : null,
        'mainEntityOfPage' => ['@type' => 'WebPage', '@id' => request()->url()]
    ]
],
```

Sem `eventStatus`, `eventAttendanceMode`, `offers`, `performer` ou `url`; `image` pode ser `null`; `mainEntityOfPage.@id` usa a URL completa `[C]`.

```js
// V2/Modules/Publicacoes/Agenda/Views/portal/show.blade.php:395-412 — filtro da programação no cliente
var terms = term.split(/\s+/);
var visible = 0;

rowCache.forEach(function(r) {
    var match = terms.every(function(t) {
        try { return new RegExp(t, 'i').test(r.text); }
        catch (e) { return r.text.indexOf(t) !== -1; }
    });
    r.el.style.display = match ? '' : 'none';
    if (match) visible++;
});

if (visible === 0) {
    if (searchTermSpan) searchTermSpan.textContent = searchInput.value.trim();
    noResults.style.display = 'block';
} else {
    noResults.style.display = 'none';
}
```

O termo é tratado como regex por palavra; entradas como `[` caem no `catch` e usam `indexOf` `[C]`.

### 2.5 Algoritmos, consultas e custos

- **Listagem**: 1 count + 1 select (12) + 1 select de mídias (eager) + 1 `SEOService::trackAccess()` por render (o composer roda em `metadata`, `metadata.seo` etc.; o serviço tem buffer) `[I]`.
- **Detalhe**: 1 select por slug + 1 select de mídia; nenhum `with`, a relação é lazy `[C]`.
- **`ilike`**: sem índice; recorte do mês/semana reduz o custo `[C]`.
- **Cache**: HIT nem executa PHP do controller; MISS paga queries + render `[C]`.
- **Chave**: `page:<sha1(fullUrl)>`, então cada combinação de filtros é uma entrada; TTL 5 min `[C]`.
- **Invalidação**: qualquer escrita no CMS apaga `page:*` (global) `[C]`.

## 3. Particularidades

### 3.1 O que só existe aqui

- Listagem sem layout global (header/footer/acessibilidade desligados).
- Três filtros de calendário com precedência e um "Hoje" calculado na view.
- Busca interna na programação, sem endpoint.
- JSON-LD `Event` montado na view, não no controller.

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-AGENDA-02 | Listagem exige `published`+`active`; detalhe só `published` | Desativado ainda abre por URL | `EventosPortalController.php:40,113` |
| RN-AGENDA-03 | Sem filtro, só `event_date_start >= hoje` | Passado só via filtro | `Models/Evento.php:270-273` |
| RN-AGENDA-04 | Precedência `date > week > month` | Filtros extras ignorados | `EventosPortalController.php:45-60` |
| RN-AGENDA-05 | Semana domingo–sábado | Badge/título usam os mesmos limites | `Models/Evento.php:327-334` |
| RN-AGENDA-06 | Sem término = contínuo | Aparece em todos os recortes posteriores | `Models/Evento.php:301-315` |
| RN-AGENDA-24 | `$isFiltered` inclui `search` | "Todos" fica outline ao buscar | `index.blade.php:7` |
| RN-AGENDA-25 | Badge de filtro só aparece se `$filterLabel` (não para busca) | Busca sem data não mostra badge | `index.blade.php:92-101` |
| RN-AGENDA-26 | Cache key inclui query | Cada filtro/página é uma entrada | `PageCacheMiddleware.php:88-91` |
| RN-AGENDA-27 | `canonical` e `og:url` = URL sem query | Páginas filtradas canonicalizam para `/agenda` | `metadata/seo.blade.php:9,31` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Busca sem resultados**: como `$isFiltered` é verdadeiro, aparece `Limpar filtros`; sem busca/filtro, aparece o texto "Em breve divulgaremos nossa agenda de eventos." `[C]` (`index.blade.php:152-160`).
- **`date` e `week` juntos**: `date` vence e o pill de "Hoje" só fica ativo se a data for exatamente hoje `[C]`.
- **`week` = hoje**: o pill "Esta semana" compara com o domingo da semana, não com a data enviada; enviar `week=2026-10-21` (quarta) não destaca o pill `[C]`.
- **Mês**: badge `$filterLabel` e título usam `F/Y` em inglês; a UI mistura pt-BR e inglês `[C]`.
- **Evento sem imagem**: card usa `event-default.svg`; detalhe omite o bloco de capa `[C]`.
- **Evento com `event_date_end` no mesmo dia**: mostra `09:00 – 18:00` e não repete a data `[C]` (`show.blade.php:88-107`).
- **Título do documento (detalhe)**: como `SEOService` não recebe `setTitle`, `$seoTitle` cai no título do site e o `<title>` concatena ` | {siteTitle}` de novo → duplicação `[I]` (`SEOServiceProvider.php:47`; `metadata/seo.blade.php:2`).
- **Description (detalhe)**: `bindModel` não tem `metadata.seo`, então `$seoDescription` cai na description global do portal — a descrição do evento passada como `pageDescription` é ignorada `[I]`.
- **OG image**: `metadata.seo` prefere `$seo->getCoverImage()`; sem `metadata.seo.image_url`, usa o logo do site (ou vazio), ignorando a capa do evento `[C]` (`metadata/seo.blade.php:12-21`; `SEOService.php:375-397`).
- **Canonical de página filtrada**: aponta para `/agenda`, sinalizando duplicidade `[C]`.
- **Flash invisível**: nenhuma view do portal renderiza `flash::message` `[C]`.
- **Cache com HTML de outro usuário**: não há personalização; ok para conteúdo público `[C]`.
- **Breadcrumb duplicado no detalhe**: a seção `breadcrumbs` já inclui o título como item ativo e o `main-header` adiciona outro `<li>` ativo com `$title`; o usuário vê `Início › Agenda › Evento › Evento` `[C]` (`show.blade.php:49-58`; `V2/layouts/portal/main-section/main-header.blade.php:4-14`).

### 3.4 Armadilhas de reimplementação

- Não adicionar `active()` no detalhe sem plano de redirecionamento: links e sitemap existentes quebrariam.
- Não trocar `upcoming()` por "mostrar tudo": a home e a listagem passariam a exibir eventos encerrados em massa.
- Não remover o `orWhereNull` dos scopes sem revisar o acervo (eventos de longa duração).
- Não usar `url()->full()` no canonical: o legado usa `current()` (sem query); mudar altera a indexação.
- Não montar JSON-LD no controller sem manter o fallback `$schemas`: o `metadata.seo` imprime `$seoSchemas` e `$schemas` em blocos distintos.
- Não exibir `{!! description !!}` sem o renderer/escape: o legado só usa HTML quando `$htmlContent` não é vazio.

### 3.5 Divergências e dívidas

- `pageDescription` da listagem (`'Confira a agenda...'`) nunca é usado, porque `$seoDescription` do controller vence `[C]`.
- `show` passa `coverImage` mas o `og:image` usa o SEO/logo `[C]`.
- `title` do array do `show` (`'title' => $evento->title`) não é lido pelo `app-layout` (só `pageTitle`) `[C]`.
- `fullWidthMain=false` no detalhe e `true` na listagem, com layouts visuais distintos.
- CSS da listagem vem de arquivo versionado por `filemtime`; o detalhe usa `<style>` inline (`index.blade.php:174`; `show.blade.php:302-364`).

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura/variável | Precondição | Consumidores |
|---|---|---|---|---|
| `v2.agenda.portal.index` | rota/view | `$eventos`, `$filterDate`, `$filterWeek`, `$filterMonth`, `$searchTerm`, `$filterLabel` | controller index | menu, sitemap |
| `v2.agenda.portal.show` | rota/view | `$evento`, `$htmlContent`, `$topicos`, `$schedule` | controller show | cards, feed, links externos |
| `v2-portal::app-layout` | layout | `pageTitle`, `pageDescription`, `coverImage`, `gtag`, `schemas`, flags | — | as duas views |
| `v2-service-media::components.picture` | componente | `originalPath`, `variants`, `alt`, `class`, `lazy` | mídia existente | cards |
| `metadata.seo` | partial | depende de `$seo` (composer) | composer registrado | todas as páginas |

### 4.2 Contrato de UI/JS

- **DOM da listagem**: `.breadcrumb-wrapper`, `.search-form`, `#search`, `.news-card-items`, `.news-thumb`, `.news-content`, `.link-btn`; IDs únicos: `#search`.
- **DOM do detalhe**: `article#evento-conteudo-{uuid_com_underscore}`, `.schedule-interactive-table[data-event-id]`, `#programacao-{id}`, `#schedule-search-{id}`, `#schedule-table-{id}`, `.schedule-search-input`, `.schedule-search-clear`, `.schedule-no-results`, `.schedule-search-term`, `.schedule-row`.
- **Eventos**: `input` (debounce 200 ms), `keypress` Enter, `click` no limpar.
- **Sem JS**: tabela completa visível; busca interna não funciona; nenhum outro recurso depende de JS.
- **CSS**: listagem carrega `assets/css/modules/agenda/portal-agenda.css` com `?v=filemtime`; detalhe injeta `<style>` no `head-styles` com variáveis `--tenant-primary`.
- **Analytics**: `gtag.page_context = 'agenda'`/`'evento'` (`index.blade.php:19`; `show.blade.php:17`).

### 4.3 Ordem e ciclo de vida

1. Rota pública → `MaintenanceMiddleware` → `page.cache` → `public.cache` → controller.
2. Controller chama `SEOService` (singleton da request).
3. View estende `app-layout`; `base` inclui `metadata`, cujo composer roda `trackAccess()` e injeta `$seoTitle`, `$seoDescription`, `$seoKeywords`, `$siteTitle`.
4. `metadata.seo` imprime título, OG/Twitter, canonical, tokens e JSON-LD.
5. `page.cache` grava a resposta final (HTML + headers sem `Set-Cookie`).

### 4.4 Receitas de uso

**Link para a agenda a partir de qualquer página:**

```blade
<a href="{{ route('v2.agenda.portal.index') }}">Agenda</a>
```

**Link para um evento em um template externo:**

```blade
<a href="{{ route('v2.agenda.portal.show', ['slug' => $evento->slug]) }}">{{ $evento->title }}</a>
```

**Reaproveitar o card em outra listagem (copiando o contrato):**

```blade
@include('v2-service-media::components.picture', [
    'originalPath' => $evento->getRelationValue('image')->file_path,
    'variants' => $evento->getImageVariants(),
    'alt' => $evento->title,
    'lazy' => true,
])
```

**Forçar atualização imediata após edição fora do CMS:**

```bash
redis-cli --scan --pattern 'laravel:page:*' | xargs -r redis-cli del
```

### 4.5 Anti-uso

- **Contar com `pageDescription`/`coverImage` no `<head>`**: o composer sobrescreve com `SEOService`/configs; para mudar, chame `setDescription`/`setCoverImage` no controller.
- **Chamar `/agenda` esperando ver o passado**: sem `date/week/month` o scope `upcoming` remove tudo que já começou.
- **Usar `is_active` como "publicado"**: o detalhe ignora; a listagem também usa `published_at`.
- **Cachear filtros no CDN sem `Vary` por query**: a chave do Redis inclui query, mas um CDN mal configurado pode servir a URL errada.

### 4.6 Custos e limites

- 12 cards/página; sem limite de páginas.
- 5 min de cache de página e 60 s de cache HTTP.
- Cada URL filtrada cria uma entrada de cache (espaço cresce com combinações).
- `trackAccess` roda por render (não em HIT) `[I]`.
- Sem imagens em tamanhos responsivos no detalhe: usa a original com `max-height:420px` (`show.blade.php:64-70`).

## 5. Rotas e contratos HTTP

| Rota | Query | Cache | Efeito |
|---|---|---|---|
| `GET /agenda` | `date`, `week`, `month`, `search`, `page` | `page.cache` + `public.cache` | lista 12 |
| `GET /agenda/{slug}` | — | idem | detalhe/404 |

Contrato completo (middleware, erros, precedência) em [`rotas.md`](rotas.md).

## 6. Dados e modelo de domínio

Consultas:

- Listagem: `Evento::published()->active()` + filtro + `upcomingOrdered()` + `with('image')` + `paginate(12)` (`EventosPortalController.php:40-71`).
- Detalhe: `Evento::where('slug',$slug)->published()->firstOrFail()` (`:113`).
- Campos exibidos: título, datas, local, organizador, descrição, tópicos, público-alvo, programação, imagem. Schema em [`dados.md`](dados.md).

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> ListaFuturos
  ListaFuturos --> ListaData: ?date
  ListaFuturos --> ListaSemana: ?week
  ListaFuturos --> ListaMes: ?month
  ListaData --> Detalhe: clique no card
  ListaSemana --> Detalhe
  ListaMes --> Detalhe
  ListaFuturos --> Detalhe
  ListaFuturos --> Vazio: sem eventos
  Detalhe --> ListaFuturos: Voltar para Agenda
```

## 8. UI CMS

Não se aplica; ver [`cms.md`](cms.md).

## 9. UI Portal

### 9.1 Listagem `/agenda`

| Seção | Conteúdo |
|---|---|
| Hero | `breadcrumb-wrapper` com imagem `assets/sucesu/img/hero/breadcrumb.png`, breadcrumb `Home > Agenda`, `<h1>Agenda de Eventos</h1>` |
| Busca | form GET `role="search"`, `label.visually-hidden`, `#search`, botão `Buscar` |
| Pills | Todos / Hoje / Esta semana / Próximo mês |
| Filtro ativo | badge `{{ $filterLabel }}` + `Limpar filtro` |
| Grid | `row g-4`; cards `col-xl-6 col-lg-6 col-md-6` |
| Card | thumb (picture/fallback), `<h3><a>` título, data(s) `<strong>`, local, organizador, `Saiba mais` |
| Paginação | `v2-portal::partials.pagination` com `appends` |
| Vazio | ícone, `Nenhum evento encontrado`, link `Limpar filtros` ou texto `Em breve divulgaremos nossa agenda de eventos.` |

Datas no card: início em `d/m/Y`; se o término existir e for de outro dia, exibe `– dd/mm/aaaa`; hora não aparece na listagem (`index.blade.php:126-145`).

### 9.2 Detalhe `/agenda/{slug}`

| Seção | Conteúdo |
|---|---|
| Breadcrumb | `Início` (do layout) › `Agenda` › `{título}` (seção `breadcrumbs`, microdata `position` 2 e 3) › `{título}` (item ativo adicionado pelo layout) — o título aparece duas vezes (`V2/layouts/portal/main-section/main-header.blade.php:4-14`) |
| Capa | `<img>` original, `loading="eager"`, `max-height:420px`, `object-fit:cover`; omitida sem imagem |
| Header | `<h1>{título}</h1>` no `main-header` do layout; no artigo, `<h2 class="evento-title">`, data (`<time datetime>`), hora início/fim, data final se outro dia, local, organizador (`show.blade.php:74-134`) |
| Descrição | `<section class="evento-body evento-sidebar">` com `{!! $htmlContent !!}` ou `<p>{{ description }}</p>` |
| Tópicos | `<h2>Tópicos do Evento</h2>`, lista em colunas com ícone check |
| Público-alvo | `<h2>Público-Alvo</h2>` com ícone e texto |
| Programação | tabela `Horário/Atividade/Palestrante`, busca, estado "Nenhum item na programação." e "Nenhum item encontrado para **termo**." |
| Voltar | botão `Voltar para Agenda` |

### 9.3 SEO

| Item | Origem | Observação |
|---|---|---|
| `<title>` | `metadata/seo.blade.php:2` | `$seoTitle` (controller) + `" | " + $siteTitle` quando `$pageTitle` existe |
| `meta description` | `$seoDescription` | listagem fixa; detalhe cai na configuração do portal |
| `meta keywords` | `$seoKeywords` | listagem fixa; detalhe cai na configuração |
| OG/Twitter | `og:title`, `og:description`, `og:image`, `twitter:*` | `og:type` sempre `website` |
| canonical | `$seoCanonical ?? url()->current()` | sem query string |
| JSON-LD | `$seoSchemas` + `$schemas` | `CollectionPage` (lista) / `Event` (detalhe) |
| robots | não emitido | indexação permitida |
| `gtag` | `page_context` | `agenda`/`evento` |

### 9.4 Mídia

- Listagem: `picture` com um `<source type="image/webp" srcset=...>` por largura (usa `media="(min-width: Npx)"`, **sem `sizes`**) e `<img>` fallback apontando para o original com `loading="lazy"`; `alt` = título (`V2/Services/Media/Views/components/picture.blade.php:52-66`).
- Detalhe: imagem original via `getImageUrl()`; sem `srcset`; `alt` = título; sem dimensões explícitas (apenas `max-height`).
- Sem imagem: listagem usa `event-default.svg`; detalhe não renderiza capa.

### 9.5 Responsividade e print

- Grid: 2 colunas em `xl/lg/md`, 1 em telas menores (Bootstrap).
- Tabela de programação em `.table-responsive` (scroll horizontal).
- `article` com `max-width:960px` centralizado no detalhe.
- Não há folha de estilo de impressão específica; o layout global pode ter `print` partials, mas não são usados aqui `[C]`.

### 9.6 Cache

| Item | Valor |
|---|---|
| Middleware | `page.cache` (Redis, TTL 5 min) + `public.cache` (`max-age=60, must-revalidate`) |
| Chave | `page:` + `sha1(fullUrl)` com prefixo `laravel:` |
| Invalidação | `EventosCmsController::clearPageCache()` apaga `laravel:page:*` |
| Efeito | escrita no CMS reflete no próximo request; browser pode manter 60 s |
| Exclusões | paths `cms*`, `api*`, `trabalhe-conosco*`, `associe-se*`, `fale-conosco*`, AJAX |

### 9.7 Acessibilidade

- Listagem: `h1` único; breadcrumb em `<ul>`; busca com `label.visually-hidden`; pills são links com texto; cards têm `h3` e imagem com `alt`; ícones decorativos `aria-hidden`.
- Detalhe: `article` com `id`; `time` com `datetime`; tabela com `caption.visually-hidden`, `aria-label`, `thead` com `scope`; `tbody[aria-live="polite"]`; `role="status"` + `aria-live="assertive"` no "nenhum resultado"; `label.visually-hidden` no campo de busca; botão limpar com `aria-label="Limpar pesquisa"`; foco devolvido ao input ao limpar.
- Contraste: usa `--tenant-primary` sobre fundo claro; blocos com `background: var(--color-bg-canvas)`.
- Lacuna: listagem desliga a barra de acessibilidade do portal; o detalhe mantém.

## 10. Configuração, permissões e flags

- Rotas públicas não têm RBAC nem flag; dependem de `web`, `MaintenanceMiddleware`, `page.cache`, `public.cache`.
- `app.name` entra nos títulos; `app.timezone` define "hoje"/"esta semana"; `v2_portal_configs.titulo_site`/`descricao`/`palavras_chaves` são fallback do SEO.
- `--tenant-primary` e `--color-bg-canvas` vêm do tema do portal (CSS vars).
- Sem configuração própria da Agenda.

## 11. Observabilidade, erros e limites

- `Log::error('[EventosPortal] Falha ao renderizar descrição: ...')` se o renderer lançar (`EventosPortalController.php:134-136`).
- `page.cache` loga warning se o Redis falhar e serve sem cache.
- `404` para slug inexistente/rascunho; sem página customizada.
- Limite de 12 cards; sem timeout próprio; imagens originais no detalhe podem ser pesadas.
- Nenhum dado sensível exibido.

## 12. Seeds e dados iniciais

Não se aplica: as páginas dependem de eventos criados no CMS ou pelo `DemoContentSeeder` (`V2/Seeds/Content/DemoContentSeeder.php:611-668`). A BasicPage "Agenda" semeada por `AgendaSeeder` não é usada por estas rotas.

## 13. Lacunas

- **LG-AGENDA-18** — o `<title>` duplicado e a description/OG ignorando o evento dependem de `v2_portal_configs` preenchido; não foi possível confirmar os valores em produção. Onde procurar: tabela `v2_portal_configs`.
- **LG-AGENDA-19** — não há `sizes` documentado para o `picture` (o componente decide); medir LCP em produção.
- **LG-AGENDA-20** — não foi possível confirmar se a ausência de header/footer na listagem é intencional (LG-AGENDA-07).
- **LG-AGENDA-21** — o texto "Em breve divulgaremos nossa agenda de eventos." pode ser sazonal; não há CMS para editá-lo.
- **LG-AGENDA-22** — não há dados de uso do filtro de programação (JS) nem teste de acessibilidade formal.

## 14. Checklist de paridade

- [ ] `PC-AGENDA-06` — listagem padrão = futuros, asc, 12/página.
- [ ] `PC-AGENDA-07` — filtro por data inclui passados do dia e contínuos.
- [ ] `PC-AGENDA-08` — semana domingo–sábado no badge e no título.
- [ ] `PC-AGENDA-09` — mês completo com `F/Y` em inglês.
- [ ] `PC-AGENDA-10` — filtro inválido cai em futuros sem aviso visível.
- [ ] `PC-AGENDA-11` — busca pública casa título/local/organizador (não descrição).
- [ ] `PC-AGENDA-35` — listagem sem header/footer/acessibilidade; detalhe com todos.
- [ ] `PC-AGENDA-36` — card sem imagem usa `event-default.svg`; detalhe omite capa.
- [ ] `PC-AGENDA-37` — JSON-LD `CollectionPage` na lista e `Event` no detalhe, com `endDate` fallback.
- [ ] `PC-AGENDA-38` — canonical sem query em página filtrada.
- [ ] `PC-AGENDA-39` — busca da programação filtra por todas as palavras e mostra o termo no vazio.
- [ ] `PC-AGENDA-40` — `X-Cache-Status` HIT na segunda requisição e `Cache-Control` de 60 s.
- [ ] `PC-AGENDA-41` — evento publicado inativo abre no detalhe e não aparece na lista.
- [ ] `PC-AGENDA-42` — `/agenda/{slug}` de rascunho retorna 404.

## 15. Evidências consultadas

- `V2/Modules/Publicacoes/Agenda/Views/portal/index.blade.php:1-175`.
- `V2/Modules/Publicacoes/Agenda/Views/portal/show.blade.php:1-438`.
- `V2/Modules/Publicacoes/Agenda/Controllers/EventosPortalController.php:1-141`.
- `V2/layouts/portal/app-layout.blade.php:1-32`; `V2/layouts/portal/base.blade.php:25-55`; `V2/layouts/portal/metadata.blade.php:1-10`; `metadata/seo.blade.php:1-50`.
- `V2/Services/SEO/Providers/SEOServiceProvider.php:24-52`; `SEO/Services/SEOService.php:29-61,375-397`.
- `V2/Core/Middleware/PageCacheMiddleware.php:13-92`; `PublicCacheMiddleware.php:23-134`.
- `V2/Services/Media/Views/components/picture.blade.php:1-72`.
- `V2/Modules/Publicacoes/Agenda/Models/Evento.php:260-386`.
- `public/assets/css/modules/agenda/portal-agenda.css`; `public/assets/sucesu/img/event-default.svg`.
