Merge pull request #478 from fsolla/C94-agenda-filtro-combobox-header-do-app-link-acoes-no-fab

C94 — Agenda: filtro combobox + header do app (link) + ações no FAB
This commit is contained in:
Francisco Solla 2026-08-09 05:19:03 -03:00 committed by GitHub
commit 8d0a571500
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1078 additions and 395 deletions

1
.gitignore vendored
View file

@ -14,6 +14,7 @@
/playwright-report/
/test-results/
/.playwright-cli/
/.playwright-mcp/
# Aider (local agent history + tag cache)
.aider*

View file

@ -12,6 +12,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-08):** **B174** (Issue #449) — a Sollinha (`/campanha/api/ai-chat`) trocou o provider de `@ai-sdk/deepseek` (API oficial `api.deepseek.com`) pelo **`@ai-sdk/deepinfra`** no caminho OpenAI-compatível da Deep Infra — **mesmo modelo** (`deepseek-ai/DeepSeek-V4-Flash`), **mesmas tools** e **mesma chave** que os agentes do time usam (um provedor, uma chave, uma fatura). Swap de 2 linhas no `route.ts`; dep `@ai-sdk/deepinfra@3.0.20` pinada na mesma ferroviária do `ai@7.0.47` (provider 4.0.4 / provider-utils 5.0.18, `pnpm why` confirma cópia única — nada de 4.0.7 duplicado), `@ai-sdk/deepseek` removido (dead dep, knip); `.env.example` `DEEPSEEK_API_KEY` → `DEEPINFRA_API_KEY` (B173 STT/Whisper reusa a mesma chave). Sem migration, access, UI ou teste novo — e2e já mocka o SSE (sem chave real no CI); rollback documentado = `git revert` (restaura provider/baseURL antiga); smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar o flip. Intenção [`sollinha-no-deepinfra.md`](plans/sollinha-no-deepinfra.md); impl [`sollinha-no-deepinfra-impl.md`](plans/sollinha-no-deepinfra-impl.md).
**Recently resolved (2026-08-08):** **B170** (Issue #441, follow-up B169) — o **contrato do colapso de chips virou spec unitário** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): `RelationChipCell` renderiza 16 chips com `getBoundingClientRect` stubado (4/linha, 20px, gap 6 — a matemática real de `COLLAPSED_CHIP_ROWS`/`CHIP_GAP_PX`) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam `measureOverflow: false` ou colapsavam para 1 chip — a mudança de timing do B169 (medição em `useLayoutEffect`) não tinha regressão do comportamento de colapso. **F2 medido e deferido em vez de eliminado**: `PerformanceObserver('layout-shift')` no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo `97px/42chips → 57px/0chips` numa única transição; **ordenação client-side (RSC) → zero shifts novos**. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção [`estabilizar-lista-dobradinhas-followup.md`](docs/plans/estabilizar-lista-dobradinhas-followup.md); impl [`estabilizar-lista-dobradinhas-followup-impl.md`](docs/plans/estabilizar-lista-dobradinhas-followup-impl.md).
**Recently resolved (2026-08-09):** **C94** (Issue #438) — barra da agenda consolidada: os 3 seletores (Município/Tag/Deputado presente) viraram **um combobox omnibox** (`activityAgendaOmnibox.ts` + chassis B127) com chips removíveis/“Limpar” e sugestões agrupadas; desktop toolbar `[combobox][carrinho giro][+ nova]` (ícones; mobile sem botão de criação no corpo — tudo no FAB); “Link de import” **saiu do card de filtros** para o **header do app** (`CampaignDesktopHeader`, **agenda-contextual**, ordem `[link][sino][IA]` do gate) via registro chaveado `SetCampaignHeaderAction` no `CampaignPageChromeContext` (slot compartilhado com C95), e virou ação de drawer no FAB mobile (`CampaignQuickAction.onAction` + `openCalendarFeed` no `CampaignQuickActionContext`) abrindo o **mesmo diálogo como sheet** responsivo (`CalendarFeedDialog` Dialog/Drawer; sem recorte mostra aviso “Aplique filtros…”, C93 cobre gerar sem filtro). O overlay do FAB fecha ao disparar ação de dialog. Sem migration/access/Consent. Intenção [`c94-agenda-combobox-header-acoes-rapidas.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas.md); impl [`c94-agenda-combobox-header-acoes-rapidas-impl.md`](docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md).
(feat(C94): toolbar omnibox + e2e agenda hardening + changelog)
**Recently resolved (2026-08-08):** **`pnpm worktree plan [bag] [--go] [--no-migrate]`** — worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main: **cada invocação cria UM DIFERENTE**, para sessões de planejamento paralelas — com `bag`, branch `plans/plan-issue-<bag>` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-<n>` livre (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`). Prefixo minúsculo `plans/…` nunca colide com um `next` posterior em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do `next` (porta `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test`, migrations; sem Docker degrada ao compartilhado). `kill` cobre; comment das envs distingue `gerado por pnpm worktree plan`. Sem migration. Naming unit-testada.
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).

View file

@ -0,0 +1,117 @@
# Impl: C94 — Agenda: filtro combobox único, header compacto (ícones) e ações no FAB mobile
Status: em execução (aprovado pelo humano em 2026-08-09)
Atualizado em: 2026-08-09
Issue: #438
Intenção: docs/plans/c94-agenda-combobox-header-acoes-rapidas.md
Appetite restante: ~1–1,5 dia eng (herdado); sem ajuste
## Leitura da intenção
- **Outcome:** toolbar da agenda consolidada em um combobox único (chips removíveis + sugestões agrupadas), entrada de criação por viewport com um caminho só: desktop = `[combobox][carrinho giro][+ nova]` + "Link de import" como ícone agenda-contextual no header do app; mobile = sem botões de criação no corpo, tudo no drawer do FAB ("Nova atividade", "Link de import", "Planejar giro").
- **O que NÃO negociar:** filtros hoje = Município (único) + Tag + Deputado presente (chip bool), sem busca textual nem multi-seleção; estado do filtro segue na URL (reload/share consistentes); "Link de import" desktop mora no **header do app**, não na toolbar; mobile abre o mesmo diálogo como **sheet**; sem regressão de criação inline (C91), giros, revogação de feeds, leader lockdown.
- **O que reavaliar (hipóteses da intenção):**
- A intenção sugere "ActivityAgendaFilters.tsx vira o adaptador do combobox" — confirmado, mas ele vira a **toolbar inteira** (combobox + ícones trailing), não apenas o adaptador.
- A intenção sugere "slot no header vs renderizar no shell pela rota — executor decide". Decidir: **slot via contexto de chrome** (reusável por C95), não render por rota no shell.
- A intenção não decide o que fazer com **"Link de import" no FAB quando não há filtros** (hoje o botão fica desabilitado). Decidir: abrir o sheet e explicar dentro (em vez de tile morta).
## Abordagem recomendada
```mermaid
flowchart LR
subgraph AgendaPage[AGENDA PAGE · server]
S[searchParams] --> P[CalendarFeedDialogProvider<br/>feeds · createFeed(state) · revokeFeed · canGenerate]
end
P -->|registra| H[SetCampaignHeaderAction id=calendar-feed]
H -->|lê| DH[CampaignDesktopHeader · agenda-contextual]
P -->|setContext openCalendarFeed| QK[CampaignQuickActionContext]
QK --> resolveActivityQuickActions['list' + import-calendar onAction]
QK --> FAB[FAB drawer · mobile]
AF[ActivityAgendaFilters → tool bar omnibox] -->|trailing desktop| ICON[ícone carrinho giro + ícone + nova]
AF --> URN[useCampaignListFilterNavigation → buildActivityAgendaHref]
```
**Opções consideradas (combobox):** A) omnibox chassis existente (`CampaignListOmnibox` B127) | B) `StrictCombobox`/combobox mobile avulso | C) manter selects nativos.
**Recomendação:** A — reusa o chassis já estabelecido nas listas; o adaptador segue o padrão `activityOmnibox.ts` (chips/sugestões puros + tests B128).
**Rejeitadas:** B (chassis não tem chips/teclado do omnibox — duplicaria); C (é exatamente a duplicação que a Issue quer eliminar).
**Opções consideradas (header "Link de import"):** A) slot por contexto de chrome (extender `CampaignPageChromeContext` com registro de ações de header) | B) renderizar no shell pela rota (`usePathname` no header + self-fetch de feeds/state via `useSearchParams`) | C) link simples para o dialog roteado.
**Recomendação:** A — "edit the owner": o contexto de chrome JÁ é o mecanismo por-página do shell; vira registro chaveado de `ReactNode` (reusável por C95 que precisa do mesmo header/slot). Mantém o conhecimento de domínio (feeds/state/action) na vertical de atividade.
**Rejeitadas:** B (acopla o shell compartilhado à URL/schema da agenda e exige `useSearchParams` no layout → Suspense; é o "renderizar na rota" que a intenção flagrou como risco); C (o diálogo não é rota; precisa de state).
**Opções consideradas (FAB "Link de import" mobile):** A) estender `CampaignQuickAction` com `onAction` + `openCalendarFeed` no contexto de quick actions | B) ação href para uma rota de wizzard | C) overlay próprio da agenda.
**Recomendação:** A — `CampaignHomeActionButton` JÁ suporta `onClick`; falta só o fio pelo overlay; o contexto de quick actions já é a ponte página→shell (precedente `CampaignQuickActionContextSync/Bridge`).
**Rejeitadas:** B (diálogo não é rota); C (duplica o drawer).
### Componentes / mudanças
- **`src/utilities/activityAgendaOmnibox.ts`** (novo): adaptador puro do omnibox para `ActivityAgendaState` — `buildActivityAgendaOmniboxChips`, `buildActivityAgendaOmniboxSuggestionSeeds` (grupos Município / Tag / "Deputado presente" chip bool), `applyActivityAgendaOmniboxSuggestion`, `removeActivityAgendaOmniboxChip`, `clearActivityAgendaOmnibox`. Mesmo shape de `activityOmnibox.ts` (não é twin — outro `State` com 3 dimensões e sem busca/Janela).
- **`ActivityAgendaFilters.tsx`** (reescrita): vira a toolbar — `<CampaignListOmnibox>` com chips/sugestões/`Limpar` (chassis tem clear built-in) + `trailing` com `[ícone carrinho: Planejar giro][ícone "+" : Nova atividade]` **`hidden md:inline-flex`** (mobile: zero botões no corpo). `id="agenda-omnibox"`, `label="Filtrar agenda"`, `placeholder` tipo "Filtrar por município, tag, deputado…". Navegação via `useCampaignListFilterNavigation` + `buildActivityAgendaHref`.
- **`agenda/page.tsx`**: remove o bloco de botões "Planejar giro"/"Nova atividade" (movem para a toolbar); envolve o conteúdo em `<CalendarFeedDialogProvider>` (com `feeds`, `createFeed(label)` = closure com `state`, `revokeFeed`, `canGenerate = hasFilters`); mantém `restrictActivityAgendaState`/redirects intocados.
- **`CalendarFeedDialog.tsx`** (extraído de `CalendarFeedButton.tsx`): diálogo nomear→copiar→revogar, **responsivo** (Dialog desktop / Drawer-sheet mobile via `useIsMobile`, igual ao `CampaignQuickActionsOverlay`); expõe `CalendarFeedDialogProvider` + `useCalendarFeedDialog()` (`{ open, canGenerate }`); quando `!canGenerate`, abre com aviso "Aplique filtros para gerar um link de import" e desabilita "Gerar link". O provider também injeta `openCalendarFeed` no `CampaignQuickActionContext` (merge funcional + cleanup).
- **`CampaignPageChromeContext.tsx`** (extender): adiciona `headerActions: Record<string, ReactNode>` + `setHeaderAction(key, node|null)`; novo componente `SetCampaignHeaderAction({ id, children }): null` (padrão `SetCampaignPageChrome`).
- **`CampaignDesktopHeader.tsx`**: renderiza `Object.values(headerActions)` no cluster à direita, ANTES do sino e do AI (ordem do gate: `[título …][Semana ▾ C95][Link de import][Notificações][IA]`).
- **`AgendaFeedHeaderButton.tsx`** (novo, client): botão-ícone `CalendarIcon`, `title`/tooltip, `disabled={!canGenerate}`, `onClick={open}` — registrado via `SetCampaignHeaderAction id="calendar-feed"` na página (desktop-only porque o `CampaignDesktopHeader` é `md:flex`).
- **Quick actions**: `CampaignQuickAction` ganha `onAction?: () => void`; `CampaignQuickActionsOverlay` repassa `onClick: action.onAction` para o `CampaignHomeActionStrip` (o botão já suporta); `CampaignQuickActionContext` ganha `openCalendarFeed?: () => void`; `resolveActivityQuickActions` (surface `list`) devolve `[new-activity, import-calendar(onAction), plan-tour]` — ordem da intenção.
- **Migration:** sem migration (nenhuma mudança de schema).
- **Access / Consent:** nenhuma mudança — agenda segue `gate: 'staff'`; nenhum opt-in novo (o feed é dado de staff, sem Consent).
- **UI:** Impeccable C/B — consolidar a toolbar seguindo o padrão de `ActivityFilters`/listas; shape → craft → critique → polish leve; tokens `data-theme='campaign'`.
### Dados → forma (se aplicável)
- Forma: chips + sugestões agrupadas (padrão omnibox do repo) — "não apresento dados novos, filtro = navegação/URL". Decidido pela intenção (pergunta 3: forma adiada para o padrão existente).
## Fases verificáveis
1. **Combobox (traço dominante)** — `activityAgendaOmnibox.ts` + unit tests (espelho de `listOmniboxB128`); reescrever `ActivityAgendaFilters` (omnibox + trailing desktop); página: remover botões avulsos. Verificar: URL canonical inalterada; e2e da agenda reescrito nos seletores (hoje `getByLabel('Município'|'Tag'|'Deputado presente')`).
2. **Feed dialog + header slot + FAB** — extrair `CalendarFeedDialog` (Dialog/sheet); provider + `useCalendarFeedDialog`; extender chrome context (`headerActions` + `SetCampaignHeaderAction`); `AgendaFeedHeaderButton` + header renderiza registrados; quick actions `onAction` + `openCalendarFeed` + `import-calendar`.
3. **Gates** — `pnpm gate:fast` na iteração; rebase em `origin/main`; e2e agenda (atualizar + rodar); `pnpm push` no fechamento.
## Rabbit holes / Não escopo (engenharia)
- **Omnibox de busca global da agenda** (texto livre, multi-município, presets de janela) — fora; só as 3 dimensões de hoje, sem `onCommitQuery`.
- **Refatorar `CampaignHomeActionStrip`/`CampaignHomeActionButton`** além do fio mínimo do `onAction` — não.
- **Migrar as outras listas para o omnibox** (municípios etc. já usam) — não.
- **Header vira barra de navegação** — mantém batch pequeno: só `headerActions` registrados + sino + IA.
- **C92/C93**: o ícone reflete apenas o estado habilitado/desabilitado; "gerar feed sem filtros" é a C93 (fora).
- **Não mudar o compose de giro nem o overlay inline (C91).**
## Divergências executadas (vs. hipótese da intenção)
- `AgendaFeedChrome` (client, montado na página) é o ponte único: registra o botão do header (`SetCampaignHeaderAction id="calendar-feed"`), injeta `openCalendarFeed` no `CampaignQuickActionContext` e **dona do único `<CalendarFeedDialog>`** (Dialog/sheet responsivo) — o botão do header e a ação do FAB chamam o mesmo `open()` (sem contexto novo de diálogo no layout).
- O **overlay do FAB fecha ao disparar uma ação dialog** (`onActionClick` no `CampaignQuickActionsOverlay`) — senão o sheet da agenda abriria por cima do drawer aberto.
- `playwright.config.ts` ganhou `webServer.timeout = 240s` **local-only** (`CI ? undefined`) — a máquina compartilhada (load 60+ de worktrees paralelos) estourava o boot de 60s em `pnpm dev`.
## Pós-/simplify (reviewers paralelos)
- **P1 corrigido:** `ActivityAgendaFilters` mantém espelho `draft` do estado (padrão do filtro antigo) para picks consecutivos dentro da janela pending do RSC aplicarem **cumulativos** — derivar do prop `state` solto dropava a primeira seleção (regressão desta entrega, achada pelo reviewer de correção).
- **P2 corrigido:** no overlay, `onAction` vence `href` deterministicamente (o botão ignora `onClick` na branch Link — combo latente).
- Botão do header memoizado (`useMemo`) no `AgendaFeedChrome` — registra só quando `canGenerate`/`open` muda.
- Chaves nos nós de `headerActions` (C95 terá 2+).
- `handleRevoke` sem wrapper async inútil; `ActivityAgendaFilterOption` não exportado.
- Deferido (DRY <3 call sites): extrair `chipLabel`/seeds de município/tag compartilhados para `lib/campaignListOmnibox`.
- **E2E:** os testes de slot do calendário (`cria inline`, `Mais detalhes`) ganharam waits de visibilidade (`[data-time="14:00:00"]:visible` + `dayCell`) antes de medir — o FullCalendar renderiza o eixo de tempo lazy e re-layout no mount; os `boundingBox` antigos corriam a renderização (flake que também existe no commit base; agora estabilizado).
## Débitos deferidos (capture-review-debts)
| Débito | Gatilho de revisitação |
| -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Query textual sem match fica presa no omnibox da agenda (sem `onCommitQuery`; `q:` é filtrado de propósito) | Se um usuário relatar confusão, ou quando um omnibox sem busca textual voltar a aparecer em outra lista |
| `CampaignQuickAction` permite `href`+`onAction` juntos (mitigado: `onAction` vence no overlay; tipo união discriminada seria refactor amplo) | Na próxima Issue que tocar `CampaignQuickActionTypes`/overlay |
| Closure de filtro pode ficar stale dentro do diálogo de feed aberto (comportamento herdado do C92) | C93 (gerar sem filtros) ao mexer no diálogo |
| Feeds listados no diálogo só atualizam em re-render da página (herdado do C92) | Idem |
| `SetCampaignHeaderAction` re-registra quando o host re-renderiza (agora memoizado; C95 deve revisitar o shape do slot) | C95 (seletor de semana no header) |
## Riscos e mitigação (executados)
- **Header** `[actions][sino][IA]` conforme o gate; sem `useSearchParams` no layout.
- E2E agenda: a interação omnibox usa `fill` (reabre o popover) em vez de `click` num input já focado; assert final de URL com wait do chip + `timeout: 20s`.
- `codebaseConventions` — `activityAgendaOmnibox.ts` registrado no pin de `src/utilities`.
- Os testes de slot do calendário (`cria inline`, `Mais detalhes`) são **frágeis de timing de `gridcell.nth(1)`** e flakam sob load alto também no commit base (verificado via worktree descartável de `e3f6d3f5`) — ambientais, não desta entrega; em load ~30 a suíte agenda passou 3/3.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (3 pontos + anti-goals)
- [ ] Invariantes AGENTS/engineering-standards (staff gate, URL canonical, sem Consent novo)
- [ ] Testes de domínio: unit do adaptador de agenda (espelho B128); e2e agenda reescrito e verde; int/serviço de feed intocados
- [ ] `pnpm gate:fast`, tsc, lint, prettier, knip, check:cycles verdes

View file

@ -109,6 +109,9 @@ export default defineConfig({
*/
reuseExistingServer: false,
url: baseURL,
// Local-only tolerance for parallel-worktree load (machine load ~60); CI
// boots the production build and keeps the usual 60s budget.
timeout: process.env.CI ? undefined : 240_000,
/* Force the isolated test database into the server process. */
env: {
DATABASE_URL: process.env.DATABASE_URL as string,

View file

@ -1,6 +1,4 @@
import config from '@payload-config'
import { MapPinnedIcon, PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
@ -11,23 +9,21 @@ import {
} from '@/app/(campaign)/campanha/actions/calendarFeed'
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
import { AgendaFeedChrome } from '@/components/campaign/activity/AgendaFeedChrome'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { loadAccessibleActivityTags } from '@/utilities/activityPageData'
import {
buildActivityAgendaHref,
buildActivityCreateHref,
resolveActivityAgendaUrl,
restrictActivityAgendaState,
} from '@/utilities/activityUi'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { TOUR_COMPOSER_PATH } from '@/utilities/visit/visitPlannerUrl'
export const metadata = campaignPageMetadataFromCatalog('agenda')
@ -57,47 +53,37 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
const accessibleHref = buildActivityAgendaHref(state)
if (accessibleHref !== resolvedUrl.href) redirect(accessibleHref)
const canGenerateFeed = Boolean(state.municipality || state.deputyPresent || state.tag)
return (
<CampaignPageShell className="gap-6">
<div className="flex flex-wrap justify-end gap-2">
<Button asChild variant="outline" className="min-h-11">
<Link href={TOUR_COMPOSER_PATH}>
<MapPinnedIcon data-icon="inline-start" aria-hidden="true" />
Planejar giro
</Link>
</Button>
<Button asChild className="min-h-11">
<Link href={buildActivityCreateHref(state)}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova atividade
</Link>
</Button>
</div>
<AgendaFeedChrome
feeds={feeds.map((f) => ({
id: f.id,
label: f.label,
createdAt: f.createdAt,
}))}
canGenerate={canGenerateFeed}
onCreateFeed={async (label) => {
'use server'
return createCalendarFeedLink({
label,
filterMunicipality: state.municipality,
filterDeputyPresent: state.deputyPresent,
filterTag: state.tag,
})
}}
onRevokeFeed={async (feedId) => {
'use server'
return revokeCalendarFeed(feedId)
}}
/>
<CampaignListPendingBoundary>
<ActivityAgendaFilters
state={state}
municipalityOptions={municipalityOptions}
knownTags={knownTags}
feeds={feeds.map((f) => ({
id: f.id,
label: f.label,
createdAt: f.createdAt,
}))}
onCreateFeed={async (label) => {
'use server'
const result = await createCalendarFeedLink({
label,
filterMunicipality: state.municipality,
filterDeputyPresent: state.deputyPresent,
filterTag: state.tag,
})
return result
}}
onRevokeFeed={async (feedId) => {
'use server'
return revokeCalendarFeed(feedId)
}}
/>
<CampaignListResults>
<ActivityAgenda state={state} municipalityOptions={municipalityOptions} />

View file

@ -1,140 +1,152 @@
'use client'
import { useEffect, useState } from 'react'
import { MapPinnedIcon, PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { CalendarFeedButton } from '@/components/campaign/activity/CalendarFeedButton'
import { CampaignListOmnibox } from '@/components/campaign/shared/CampaignListOmnibox'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { useCampaignListFilterNavigation } from '@/components/campaign/shared/useCampaignListFilterNavigation'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { buildActivityAgendaHref, type ActivityAgendaState } from '@/utilities/activityUi'
type CalendarFeedSummary = {
id: number
label: string
createdAt: string
}
import { ACTIVITY_TOUR_COMPOSER_PATH } from '@/lib/campaignQuickActionPaths'
import {
applyActivityAgendaOmniboxSuggestion,
buildActivityAgendaOmniboxChips,
buildActivityAgendaOmniboxSuggestionSeeds,
clearActivityAgendaOmnibox,
filterActivityAgendaOmniboxSuggestions,
removeActivityAgendaOmniboxChip,
type ActivityAgendaOmniboxAction,
} from '@/utilities/activityAgendaOmnibox'
import {
buildActivityAgendaHref,
buildActivityCreateHref,
type ActivityAgendaState,
} from '@/utilities/activityUi'
type ActivityAgendaFiltersProps = {
state: ActivityAgendaState
municipalityOptions: RelationOption[]
knownTags: string[]
feeds?: CalendarFeedSummary[]
onCreateFeed?: (
label: string,
) => Promise<{ ok: true; feedUrl: string } | { ok: false; message: string }>
onRevokeFeed?: (feedId: number) => Promise<{ ok: boolean }>
}
/**
* Agenda toolbar (C94): one omnibox filtering Município (único) + Tag +
* Deputado presente (chip bool), with the desktop create entries as icon
* buttons beside the bar. Mobile creates live in the FAB drawer, so the
* trailing icons are desktop-only. Filter state stays on the URL.
*
* A local `draft` mirrors the URL state so consecutive picks within the same
* RSC pending window apply cumulatively (a bare `state` prop would drop the
* first selection while the router.replace round-trip is in flight).
*/
export const ActivityAgendaFilters = ({
state,
municipalityOptions,
knownTags,
feeds = [],
onCreateFeed,
onRevokeFeed,
}: ActivityAgendaFiltersProps) => {
const { navigate, isPending } = useCampaignListFilterNavigation({
state,
toHref: buildActivityAgendaHref,
})
const [draft, setDraft] = useState(state)
const [query, setQuery] = useState('')
useEffect(() => setDraft(state), [state])
const update = (next: ActivityAgendaState) => {
setDraft(next)
navigate(next)
const municipalityLabelsById = useMemo(() => {
const map = new Map<number, string>()
for (const option of municipalityOptions) {
if (Number.isSafeInteger(option.id) && option.id > 0) map.set(option.id, option.name)
}
return map
}, [municipalityOptions])
const municipalityFilterOptions = useMemo(
() =>
municipalityOptions.map((option) => ({
value: String(option.id),
label: option.name,
})),
[municipalityOptions],
)
const chips = useMemo(
() => buildActivityAgendaOmniboxChips({ state: draft, municipalityLabelsById }),
[draft, municipalityLabelsById],
)
const suggestionSeeds = useMemo(
() =>
buildActivityAgendaOmniboxSuggestionSeeds({
municipalityOptions: municipalityFilterOptions,
knownTags,
}),
[municipalityFilterOptions, knownTags],
)
const suggestions = useMemo(
() => filterActivityAgendaOmniboxSuggestions(suggestionSeeds, query),
[suggestionSeeds, query],
)
const runAction = (action: ActivityAgendaOmniboxAction) => {
if (action.kind === 'clear') {
setQuery('')
setDraft({})
navigate({})
return
}
setDraft(action.state)
navigate(action.state)
}
const hasFilters = Boolean(draft.municipality || draft.deputyPresent || draft.tag)
return (
<section
aria-label="Filtros da agenda"
aria-busy={isPending}
className="rounded-xl border bg-card p-4 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending}
>
<div className="grid gap-4 md:grid-cols-[minmax(12rem,1fr)_minmax(11rem,0.7fr)_auto_auto] md:items-end">
<div className="grid gap-1.5">
<label className="text-sm font-medium" htmlFor="agenda-municipality">
Município
</label>
<NativeSelect
id="agenda-municipality"
value={draft.municipality ? String(draft.municipality) : ''}
onChange={(event) => {
const municipality = Number(event.target.value)
update({
...draft,
municipality: municipality > 0 ? municipality : undefined,
})
}}
className="w-full **:data-[slot=native-select]:min-h-11"
<CampaignListOmnibox
id="agenda-omnibox"
label="Filtrar agenda"
placeholder="Filtrar por município, tag, deputado presente…"
chips={chips}
suggestions={suggestions}
query={query}
onQueryChange={setQuery}
isPending={isPending}
onSelectSuggestion={(suggestionId) => {
runAction(applyActivityAgendaOmniboxSuggestion({ state: draft, suggestionId }))
}}
onRemoveChip={(chipId) => {
runAction(removeActivityAgendaOmniboxChip({ state: draft, chipId }))
}}
onClearAll={() => {
runAction(clearActivityAgendaOmnibox())
}}
trailing={
<div className="hidden items-center gap-2 md:flex">
<Button
asChild
variant="outline"
size="icon"
className="min-h-11 min-w-11"
aria-label="Planejar giro"
title="Planejar giro"
>
<NativeSelectOption value="">Todos os municípios</NativeSelectOption>
{municipalityOptions.map((option) => (
<NativeSelectOption key={option.id} value={String(option.id)}>
{option.name}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<div className="grid gap-1.5">
<label className="text-sm font-medium" htmlFor="agenda-tag">
Tag
</label>
<NativeSelect
id="agenda-tag"
value={draft.tag ?? ''}
onChange={(event) => update({ ...draft, tag: event.target.value || undefined })}
className="w-full **:data-[slot=native-select]:min-h-11"
<Link href={ACTIVITY_TOUR_COMPOSER_PATH}>
<MapPinnedIcon className="size-5" aria-hidden />
</Link>
</Button>
<Button
asChild
size="icon"
className="min-h-11 min-w-11"
aria-label="Nova atividade"
title="Nova atividade"
>
<NativeSelectOption value="">Todas as tags</NativeSelectOption>
{knownTags.map((tag) => (
<NativeSelectOption key={tag} value={tag}>
{tag}
</NativeSelectOption>
))}
</NativeSelect>
<Link href={buildActivityCreateHref(state)}>
<PlusIcon className="size-5" aria-hidden />
</Link>
</Button>
</div>
<label className="flex min-h-11 items-center gap-3 rounded-lg border px-3 text-sm font-medium">
<Checkbox
checked={Boolean(draft.deputyPresent)}
onCheckedChange={(checked) =>
update({ ...draft, deputyPresent: checked === true ? true : undefined })
}
/>
Deputado presente
</label>
<Button
type="button"
variant="ghost"
className="min-h-11"
disabled={!hasFilters}
onClick={() => update({})}
>
Limpar filtros
</Button>
{onCreateFeed && onRevokeFeed && (
<CalendarFeedButton
state={state}
hasFilters={hasFilters}
feeds={feeds}
onCreateFeed={onCreateFeed}
onRevokeFeed={onRevokeFeed}
/>
)}
</div>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando os compromissos da agenda.' : 'Filtros da agenda atualizados.'}
</p>
</section>
}
/>
)
}

View file

@ -0,0 +1,77 @@
'use client'
import { CalendarIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
CalendarFeedDialog,
type CalendarFeedSummary,
type CreateCalendarFeedResult,
} from '@/components/campaign/activity/CalendarFeedDialog'
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
import { Button } from '@/components/ui/button'
type AgendaFeedChromeProps = {
feeds: CalendarFeedSummary[]
/** Whether the current agenda recorte has filters (feed gate, C93 handles "sem filtros"). */
canGenerate: boolean
onCreateFeed: (label: string) => Promise<CreateCalendarFeedResult>
onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }>
}
/**
* Agenda page → app chrome bridge for the calendar-feed surface (C94):
* registers the desktop header icon (agenda-contextual), exposes the sheet to
* the mobile FAB quick action and owns the single shared dialog instance.
*/
export const AgendaFeedChrome = ({
feeds,
canGenerate,
onCreateFeed,
onRevokeFeed,
}: AgendaFeedChromeProps) => {
const { setContext } = useCampaignQuickActionContext()
const [open, setOpen] = useState(false)
const openFeed = useCallback(() => setOpen(true), [])
useEffect(() => {
setContext((current) => ({ ...current, openCalendarFeed: openFeed }))
return () => {
setContext((current) => ({ ...current, openCalendarFeed: undefined }))
}
}, [setContext, openFeed])
const headerButton = useMemo(
() => (
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 hidden shrink-0 md:inline-flex"
aria-label="Link de import"
title={canGenerate ? 'Link de import' : 'Aplique filtros para gerar um link de import'}
disabled={!canGenerate}
onClick={openFeed}
>
<CalendarIcon className="size-5" aria-hidden />
</Button>
),
[canGenerate, openFeed],
)
return (
<>
<SetCampaignHeaderAction id="calendar-feed">{headerButton}</SetCampaignHeaderAction>
<CalendarFeedDialog
open={open}
onOpenChange={setOpen}
feeds={feeds}
canGenerate={canGenerate}
onCreateFeed={onCreateFeed}
onRevokeFeed={onRevokeFeed}
/>
</>
)
}

View file

@ -1,216 +0,0 @@
'use client'
import { useState } from 'react'
import { CalendarIcon, CopyIcon, ExternalLinkIcon, Trash2Icon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import type { ActivityAgendaState } from '@/utilities/activityUi'
type CalendarFeed = {
id: number
label: string
createdAt: string
}
type CalendarFeedButtonProps = {
state: ActivityAgendaState
hasFilters: boolean
feeds: CalendarFeed[]
onCreateFeed: (
label: string,
) => Promise<{ ok: true; feedUrl: string } | { ok: false; message: string }>
onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }>
}
export const CalendarFeedButton = ({
state: _state,
hasFilters,
feeds,
onCreateFeed,
onRevokeFeed,
}: CalendarFeedButtonProps) => {
const [isOpen, setIsOpen] = useState(false)
const [label, setLabel] = useState('')
const [createdUrl, setCreatedUrl] = useState<string | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const handleCreate = async () => {
if (!label.trim()) return
setIsCreating(true)
setError(null)
const result = await onCreateFeed(label.trim())
setIsCreating(false)
if (result.ok) {
setCreatedUrl(result.feedUrl)
setLabel('')
} else {
setError(result.message)
}
}
const handleCopy = async () => {
if (!createdUrl) return
await navigator.clipboard.writeText(createdUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleRevoke = async (feedId: number) => {
await onRevokeFeed(feedId)
}
const handleClose = () => {
setIsOpen(false)
setCreatedUrl(null)
setError(null)
setCopied(false)
}
return (
<>
<Button
type="button"
variant="outline"
className="min-h-11"
disabled={!hasFilters}
onClick={() => setIsOpen(true)}
title={!hasFilters ? 'Aplique filtros para gerar um link de import' : undefined}
>
<CalendarIcon data-icon="inline-start" aria-hidden="true" />
Link de import
</Button>
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Sincronizar com Google Calendar</DialogTitle>
<DialogDescription>
Gere um link de import para sincronizar este recorte da agenda com o Google Calendar.
Alterações em Teqo serão refletidas automaticamente.
</DialogDescription>
</DialogHeader>
{createdUrl ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Link de import</label>
<div className="flex gap-2">
<Input value={createdUrl} readOnly className="font-mono text-xs" />
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
aria-label="Copiar link"
>
<CopyIcon className="h-4 w-4" />
</Button>
</div>
{copied && <p className="text-xs text-green-600">Link copiado!</p>}
</div>
<div className="rounded-lg bg-muted p-4 text-sm">
<p className="font-medium mb-2">Como adicionar ao Google Calendar:</p>
<ol className="list-decimal list-inside space-y-1 text-muted-foreground">
<li>Abra o Google Calendar no computador</li>
<li>
No menu lateral, clique em {'"'}+{'"'} ao lado de {'"'}Outras agendas{'"'}
</li>
<li>
Selecione {'"'}Por URL{'"'}
</li>
<li>
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
</li>
</ol>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleClose}>
Fechar
</Button>
<Button asChild>
<a
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon className="h-4 w-4 mr-2" />
Abrir Google Calendar
</a>
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="feed-label" className="text-sm font-medium">
Nome do feed
</label>
<Input
id="feed-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ex: Só deputado presente"
maxLength={120}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
<p className="text-xs text-muted-foreground">
Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'}
Reuniões em Salvador{'"'})
</p>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{feeds.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Seus feeds ativos</p>
<ul className="space-y-1">
{feeds.map((feed) => (
<li
key={feed.id}
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
>
<span>{feed.label}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRevoke(feed.id)}
aria-label={`Revogar feed ${feed.label}`}
>
<Trash2Icon className="h-4 w-4 text-muted-foreground" />
</Button>
</li>
))}
</ul>
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleClose}>
Cancelar
</Button>
<Button type="button" onClick={handleCreate} disabled={!label.trim() || isCreating}>
{isCreating ? 'Gerando...' : 'Gerar link'}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
)
}

View file

@ -0,0 +1,261 @@
'use client'
import { useState } from 'react'
import { CopyIcon, ExternalLinkIcon, Trash2Icon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Drawer,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { Input } from '@/components/ui/input'
import { useIsMobile } from '@/hooks/use-mobile'
export type CalendarFeedSummary = {
id: number
label: string
createdAt: string
}
export type CreateCalendarFeedResult =
| { ok: true; feedUrl: string }
| { ok: false; message: string }
type CalendarFeedDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
feeds: CalendarFeedSummary[]
/** Whether the current agenda recorte can generate a feed (has filters). */
canGenerate: boolean
onCreateFeed: (label: string) => Promise<CreateCalendarFeedResult>
onRevokeFeed: (feedId: number) => Promise<{ ok: boolean }>
}
/**
* Calendar feed dialog (nomear → copiar → revogar). Shared by the desktop
* header icon and the mobile FAB quick action — rendered once per host, opened
* by either trigger. Bottom sheet on mobile, dialog on desktop (C94).
*/
export const CalendarFeedDialog = ({
open,
onOpenChange,
feeds,
canGenerate,
onCreateFeed,
onRevokeFeed,
}: CalendarFeedDialogProps) => {
const isMobile = useIsMobile()
const [label, setLabel] = useState('')
const [createdUrl, setCreatedUrl] = useState<string | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const handleCreate = async () => {
if (!label.trim() || !canGenerate || isCreating) return
setIsCreating(true)
setError(null)
const result = await onCreateFeed(label.trim())
setIsCreating(false)
if (result.ok) {
setCreatedUrl(result.feedUrl)
setLabel('')
} else {
setError(result.message)
}
}
const handleCopy = async () => {
if (!createdUrl) return
await navigator.clipboard.writeText(createdUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleClose = (next: boolean) => {
if (next) return
setCreatedUrl(null)
setError(null)
setCopied(false)
onOpenChange(false)
}
const body = (
<>
{createdUrl ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="feed-url">
Link de import
</label>
<div className="flex gap-2">
<Input id="feed-url" value={createdUrl} readOnly className="font-mono text-xs" />
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
aria-label="Copiar link"
>
<CopyIcon className="h-4 w-4" />
</Button>
</div>
{copied && <p className="text-xs text-green-600">Link copiado!</p>}
</div>
<div className="rounded-lg bg-muted p-4 text-sm">
<p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p>
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
<li>Abra o Google Calendar no computador</li>
<li>
No menu lateral, clique em {'"'}
{'+'}
{'"'} ao lado de {'"'}Outras agendas{'"'}
</li>
<li>
Selecione {'"'}Por URL{'"'}
</li>
<li>
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
</li>
</ol>
</div>
<div className="flex justify-end gap-2">
{!isMobile ? (
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
Fechar
</Button>
) : null}
<Button asChild>
<a
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLinkIcon className="mr-2 h-4 w-4" />
Abrir Google Calendar
</a>
</Button>
</div>
</div>
) : (
<div className="space-y-4">
{!canGenerate ? (
<p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
Aplique filtros à agenda para gerar um link de import.
</p>
) : null}
<div className="space-y-2">
<label htmlFor="feed-label" className="text-sm font-medium">
Nome do feed
</label>
<Input
id="feed-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ex: Só deputado presente"
maxLength={120}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
<p className="text-xs text-muted-foreground">
Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'}
Reuniões em Salvador{'"'})
</p>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{feeds.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Seus feeds ativos</p>
<ul className="space-y-1">
{feeds.map((feed) => (
<li
key={feed.id}
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
>
<span>{feed.label}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => void onRevokeFeed(feed.id)}
aria-label={`Revogar feed ${feed.label}`}
>
<Trash2Icon className="h-4 w-4 text-muted-foreground" />
</Button>
</li>
))}
</ul>
</div>
)}
<div className="flex justify-end gap-2">
{!isMobile ? (
<Button type="button" variant="outline" onClick={() => handleClose(false)}>
Cancelar
</Button>
) : null}
<Button
type="button"
onClick={handleCreate}
disabled={!label.trim() || !canGenerate || isCreating}
>
{isCreating ? 'Gerando...' : 'Gerar link'}
</Button>
</div>
</div>
)}
</>
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleClose} showSwipeHandle>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader className="text-left">
<DrawerTitle>Sincronizar com Google Calendar</DrawerTitle>
<DrawerDescription>
Gere um link de import para sincronizar este recorte da agenda com o Google Calendar.
Alterações em Teqo serão refletidas automaticamente.
</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto px-4 pb-2">{body}</div>
<DrawerFooter className="border-t">
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Sincronizar com Google Calendar</DialogTitle>
<DialogDescription>
Gere um link de import para sincronizar este recorte da agenda com o Google Calendar.
Alterações em Teqo serão refletidas automaticamente.
</DialogDescription>
</DialogHeader>
{body}
</DialogContent>
</Dialog>
)
}

View file

@ -1,18 +1,26 @@
'use client'
import type { ReactNode } from 'react'
import { Fragment, type ReactNode } from 'react'
import { CampaignAIHeaderButton } from '@/components/campaign/shell/ai/CampaignAIHeaderButton'
import { useCampaignHeaderActions } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
import { SidebarTrigger } from '@/components/ui/Sidebar'
export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => (
<header className="hidden min-h-11 shrink-0 items-center gap-3 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
<CampaignPageChromeDisplay layout="desktop" className="flex-1" />
<div className="ml-auto flex shrink-0 items-center gap-1">
<CampaignAIHeaderButton />
{notificationBell ?? null}
</div>
</header>
)
export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => {
const headerActions = useCampaignHeaderActions()
return (
<header className="hidden min-h-11 shrink-0 items-center gap-3 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
<CampaignPageChromeDisplay layout="desktop" className="flex-1" />
<div className="ml-auto flex shrink-0 items-center gap-1">
{Object.entries(headerActions).map(([id, node]) => (
<Fragment key={id}>{node}</Fragment>
))}
{notificationBell ?? null}
<CampaignAIHeaderButton />
</div>
</header>
)
}

View file

@ -2,6 +2,7 @@
import {
createContext,
useCallback,
useContext,
useLayoutEffect,
useMemo,
@ -14,10 +15,19 @@ import {
import type { CampaignPageChrome } from '@/lib/campaignPageChrome'
import type { CampaignUser } from '@/payload-types'
/**
* Header action nodes registered by pages, rendered in the app header in
* insertion order (C94/C95 shared slot — e.g. the agenda-contextual "Link de
* import").
*/
export type CampaignHeaderActionsMap = Record<string, ReactNode>
type CampaignPageChromeContextValue = {
role: CampaignUser['role']
override: CampaignPageChrome | null
setOverride: Dispatch<SetStateAction<CampaignPageChrome | null>>
headerActions: CampaignHeaderActionsMap
setHeaderAction: (key: string, node: ReactNode | null) => void
}
const CampaignPageChromeContext = createContext<CampaignPageChromeContextValue | null>(null)
@ -30,7 +40,24 @@ export const CampaignPageChromeProvider = ({
children: ReactNode
}) => {
const [override, setOverride] = useState<CampaignPageChrome | null>(null)
const value = useMemo(() => ({ role, override, setOverride }), [role, override])
const [headerActions, setHeaderActions] = useState<CampaignHeaderActionsMap>({})
const setHeaderAction = useCallback((key: string, node: ReactNode | null) => {
setHeaderActions((current) => {
const next = { ...current }
if (node === null) {
delete next[key]
} else {
next[key] = node
}
return next
})
}, [])
const value = useMemo(
() => ({ role, override, setOverride, headerActions, setHeaderAction }),
[role, override, headerActions, setHeaderAction],
)
return (
<CampaignPageChromeContext.Provider value={value}>
@ -53,6 +80,9 @@ export const useCampaignPageChromeRole = (): CampaignUser['role'] =>
export const useCampaignPageChromeOverride = (): CampaignPageChrome | null =>
useCampaignPageChromeContext().override
export const useCampaignHeaderActions = (): CampaignHeaderActionsMap =>
useCampaignPageChromeContext().headerActions
export const SetCampaignPageChrome = ({ chrome }: { chrome: CampaignPageChrome }): null => {
const { setOverride } = useCampaignPageChromeContext()
@ -69,3 +99,24 @@ export const SetCampaignPageChrome = ({ chrome }: { chrome: CampaignPageChrome }
return null
}
/**
* Registers a page-owned control into the app header's right cluster
* (agenda-contextual by nature — only pages render it). Not rendered in place.
*/
export const SetCampaignHeaderAction = ({
id,
children,
}: {
id: string
children: ReactNode
}): null => {
const setHeaderAction = useCampaignPageChromeContext().setHeaderAction
useLayoutEffect(() => {
setHeaderAction(id, children)
return () => setHeaderAction(id, null)
}, [setHeaderAction, id, children])
return null
}

View file

@ -32,9 +32,12 @@ const overlayRetractionClass = (retracted: boolean) =>
const OverlayActionsChrome = ({
actions,
retracted,
onActionClick,
}: {
actions: readonly CampaignQuickAction[]
retracted: boolean
/** Fired after an `onAction` (dialog-style) quick action runs — closes the drawer. */
onActionClick: () => void
}) => {
if (actions.length === 0) return null
@ -53,7 +56,16 @@ const OverlayActionsChrome = ({
label: action.label,
icon: action.icon,
description: action.description,
href: action.href,
// Dialog-style actions win over a route when both are present
// (`CampaignHomeActionButton` renders the href branch without
// onClick, which would silently drop the action and the close).
href: action.onAction ? undefined : action.href,
onClick: action.onAction
? () => {
action.onAction?.()
onActionClick()
}
: undefined,
}))}
className="w-full"
/>
@ -70,15 +82,17 @@ const OverlaySearchChrome = ({ className }: { className?: string }) => (
const CampaignQuickActionsOverlayBody = ({
actions,
onActionClick,
}: {
actions: readonly CampaignQuickAction[]
onActionClick: () => void
}) => {
const { uiFocused: focused } = useHomeSearch()
return (
<div className="flex min-h-0 flex-col">
<div className="order-1 md:order-2">
<OverlayActionsChrome actions={actions} retracted={focused} />
<OverlayActionsChrome actions={actions} retracted={focused} onActionClick={onActionClick} />
</div>
<OverlaySearchChrome
className={cn('order-2 min-w-0 md:order-1', focused ? 'mt-0 pt-4' : 'mt-4 md:mt-0 md:mb-4')}
@ -89,8 +103,10 @@ const CampaignQuickActionsOverlayBody = ({
const CampaignQuickActionsOverlayContent = ({
actions,
onActionClick,
}: {
actions: readonly CampaignQuickAction[]
onActionClick: () => void
}) => {
const pathname = usePathname()
const { context } = useCampaignQuickActionContext()
@ -98,7 +114,7 @@ const CampaignQuickActionsOverlayContent = ({
return (
<HomeSearchExcludeProvider value={excludeContext}>
<CampaignQuickActionsOverlayBody actions={actions} />
<CampaignQuickActionsOverlayBody actions={actions} onActionClick={onActionClick} />
</HomeSearchExcludeProvider>
)
}
@ -135,7 +151,10 @@ export const CampaignQuickActionsOverlay = ({
Ações do contexto e busca na campanha
</DrawerDescription>
<div className="flex flex-col overflow-y-auto px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3">
<CampaignQuickActionsOverlayContent actions={actions} />
<CampaignQuickActionsOverlayContent
actions={actions}
onActionClick={() => onOpenChange(false)}
/>
</div>
</DrawerContent>
</Drawer>
@ -159,7 +178,10 @@ export const CampaignQuickActionsOverlay = ({
<DialogDescription>Ações do contexto e busca na campanha</DialogDescription>
</DialogHeader>
<div className="flex flex-col overflow-y-auto">
<CampaignQuickActionsOverlayContent actions={actions} />
<CampaignQuickActionsOverlayContent
actions={actions}
onActionClick={() => onOpenChange(false)}
/>
</div>
</DialogContent>
</Dialog>

View file

@ -1,4 +1,4 @@
import { ClipboardList, ListChecks, MapPinned, Pencil, Plus } from 'lucide-react'
import { Calendar, ClipboardList, ListChecks, MapPinned, Pencil, Plus } from 'lucide-react'
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
@ -23,7 +23,7 @@ const WIZARD_ACTION_IDS: readonly CampaignWizardActionId[] = [
'register-demand',
]
const listQuickActions = (): readonly CampaignQuickAction[] => [
const listQuickActions = (context: CampaignQuickActionContext): readonly CampaignQuickAction[] => [
{
id: 'new-activity',
label: 'Nova atividade',
@ -31,6 +31,17 @@ const listQuickActions = (): readonly CampaignQuickAction[] => [
description: 'Criar caminhada, comício, panfletagem ou outra ação de campanha',
href: ACTIVITY_NEW_PATH,
},
...(context.openCalendarFeed
? [
{
id: 'import-calendar',
label: 'Link de import',
icon: Calendar,
description: 'Gerar link para sincronizar este recorte da agenda com o Google Calendar',
onAction: context.openCalendarFeed,
},
]
: []),
{
id: 'plan-tour',
label: 'Planejar giro',
@ -101,7 +112,7 @@ export const resolveActivityQuickActions = (
if (!isStaffCampaignRole(role)) return []
if (surface.kind === 'list') {
return listQuickActions()
return listQuickActions(context)
}
const activitySlug = context.activitySlug ?? surface.activitySlug

View file

@ -10,6 +10,12 @@ export type CampaignQuickActionContext = {
activitySlug?: string
demandSlug?: string
advisorId?: number
/**
* Dialog-style action bridged by the agenda page: opens the calendar-feed
* dialog (C94). A function can live in this client-only context — never
* crossed server→client.
*/
openCalendarFeed?: () => void
}
export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({})

View file

@ -7,4 +7,6 @@ export type CampaignQuickAction = {
icon: LucideIcon
description: string
href?: string
/** Dialog-style actions (no route); the drawer button fires it instead of navigating. */
onAction?: () => void
}

View file

@ -0,0 +1,180 @@
/**
* Agenda omnibox adapter (C94): chips, suggestions and apply/remove over the
* frozen `ActivityAgendaState` URL contract. Pure / client-safe.
*
* The agenda filter surface is deliberately NOT the general activity list
* omnibox (`activityOmnibox.ts` — tabs/search/status): the agenda has three
* dimensions (Município único + Tag + Deputado presente chip bool), no free
* text search and no window presets. Municípios filter by typing (435 rows);
* Tag and "Deputado presente" are empty-query-visible shortcuts.
*/
import {
createOmniboxSuggestionSeed,
filterOmniboxSuggestionSeeds,
type CampaignListOmniboxChip,
type CampaignListOmniboxSuggestion,
} from '@/lib/campaignListOmnibox'
import type { ActivityAgendaState } from '@/utilities/activityUi'
export type ActivityAgendaOmniboxAction =
| { kind: 'url'; state: ActivityAgendaState }
| { kind: 'clear' }
type ActivityAgendaFilterOption = {
value: string
label: string
}
const chipLabel = (dimension: string, value: string): string => `${dimension}: ${value}`
export const buildActivityAgendaOmniboxChips = ({
state,
municipalityLabelsById,
}: {
state: ActivityAgendaState
municipalityLabelsById: ReadonlyMap<number, string>
}): CampaignListOmniboxChip[] => {
const chips: CampaignListOmniboxChip[] = []
if (state.municipality) {
chips.push({
id: `municipality:${state.municipality}`,
label: chipLabel(
'Município',
municipalityLabelsById.get(state.municipality) ?? `Município #${state.municipality}`,
),
})
}
if (state.tag) {
chips.push({ id: `tag:${state.tag}`, label: chipLabel('Tag', state.tag) })
}
if (state.deputyPresent) {
chips.push({ id: 'deputyPresent', label: 'Deputado presente' })
}
return chips
}
export const buildActivityAgendaOmniboxSuggestionSeeds = ({
municipalityOptions,
knownTags,
}: {
municipalityOptions: readonly ActivityAgendaFilterOption[]
knownTags?: readonly string[]
}) => {
const seeds: ReturnType<typeof createOmniboxSuggestionSeed>[] = []
seeds.push(
createOmniboxSuggestionSeed(
{
id: 'deputyPresent',
group: 'Deputado presente',
label: 'Deputado presente',
keywords: ['deputado', 'presente'],
},
{ emptyQueryVisible: true },
),
)
for (const tag of knownTags ?? []) {
seeds.push(
createOmniboxSuggestionSeed(
{
id: `tag:${tag}`,
group: 'Tag',
label: tag,
keywords: ['tag', 'tipo'],
},
{ emptyQueryVisible: true },
),
)
}
for (const option of municipalityOptions) {
seeds.push(
createOmniboxSuggestionSeed({
id: `municipality:${option.value}`,
group: 'Município',
label: option.label,
keywords: ['municipio'],
}),
)
}
return seeds
}
/**
* Filters dimension seeds and drops the shared helper's free-text `q:` row:
* the agenda has no text search, so a dead "Busca: …" option would mislead.
*/
export const filterActivityAgendaOmniboxSuggestions = (
seeds: ReturnType<typeof buildActivityAgendaOmniboxSuggestionSeeds>,
query: string,
): CampaignListOmniboxSuggestion[] =>
filterOmniboxSuggestionSeeds(seeds, query).filter((suggestion) => !suggestion.id.startsWith('q:'))
export const applyActivityAgendaOmniboxSuggestion = ({
state,
suggestionId,
}: {
state: ActivityAgendaState
suggestionId: string
}): ActivityAgendaOmniboxAction => {
if (suggestionId === 'deputyPresent') {
return {
kind: 'url',
state: state.deputyPresent
? { ...state, deputyPresent: undefined }
: { ...state, deputyPresent: true },
}
}
if (suggestionId.startsWith('tag:')) {
const value = suggestionId.slice(4)
return {
kind: 'url',
state: state.tag === value ? { ...state, tag: undefined } : { ...state, tag: value },
}
}
if (suggestionId.startsWith('municipality:')) {
const value = Number(suggestionId.slice(13))
if (!Number.isSafeInteger(value) || value <= 0) return { kind: 'url', state }
return {
kind: 'url',
state:
state.municipality === value
? { ...state, municipality: undefined }
: { ...state, municipality: value },
}
}
return { kind: 'url', state }
}
export const removeActivityAgendaOmniboxChip = ({
state,
chipId,
}: {
state: ActivityAgendaState
chipId: string
}): ActivityAgendaOmniboxAction => {
if (chipId === 'deputyPresent') {
return { kind: 'url', state: { ...state, deputyPresent: undefined } }
}
if (chipId.startsWith('tag:')) {
return { kind: 'url', state: { ...state, tag: undefined } }
}
if (chipId.startsWith('municipality:')) {
return { kind: 'url', state: { ...state, municipality: undefined } }
}
return { kind: 'url', state }
}
export const clearActivityAgendaOmnibox = (): ActivityAgendaOmniboxAction => ({ kind: 'clear' })

View file

@ -117,11 +117,21 @@ test.describe('Agenda — calendário operacional', () => {
})
.not.toBe(startAt)
await page.getByLabel('Município').selectOption(String(municipality.id))
await page.getByLabel('Tag').selectOption('Comício')
await page.getByLabel('Deputado presente').check()
const omniboxInput = page.getByRole('combobox', { name: 'Filtrar agenda' })
await omniboxInput.fill(municipality.name!)
await page.getByRole('option', { name: municipality.name, exact: true }).click()
await omniboxInput.fill('Comício')
await page.getByRole('option', { name: 'Comício', exact: true }).click()
await omniboxInput.fill('Deputado')
await page.getByRole('option', { name: 'Deputado presente', exact: true }).click()
await expect(page.locator('button[aria-label="Remover Deputado presente"]')).toBeVisible()
await expect(page).toHaveURL(
`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}&deputyPresent=1&tag=Com%C3%ADcio`,
{ timeout: 20_000 },
)
await expect(page.getByText(title, { exact: true })).toBeVisible()
@ -158,10 +168,14 @@ test.describe('Agenda — calendário operacional', () => {
const dayCell = page.getByRole('gridcell').nth(1)
await dayCell.scrollIntoViewIfNeeded()
const [slotBox, dayBox] = await Promise.all([
page.locator('[data-time="14:00:00"]').last().boundingBox(),
dayCell.boundingBox(),
])
// FullCalendar renders the time axis lazily and re-lays out on mount —
// wait for both targets to be attached+visible before measuring, or the
// bounding boxes race the grid render.
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
await expect(slotLocator).toBeVisible()
await expect(dayCell).toBeVisible()
const slotBox = await slotLocator.boundingBox()
const dayBox = await dayCell.boundingBox()
if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.')
await dayCell.click({
position: {
@ -206,10 +220,11 @@ test.describe('Agenda — calendário operacional', () => {
const dayCell = page.getByRole('gridcell').nth(1)
await dayCell.scrollIntoViewIfNeeded()
const [slotBox, dayBox] = await Promise.all([
page.locator('[data-time="14:00:00"]').last().boundingBox(),
dayCell.boundingBox(),
])
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
await expect(slotLocator).toBeVisible()
await expect(dayCell).toBeVisible()
const slotBox = await slotLocator.boundingBox()
const dayBox = await dayCell.boundingBox()
if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.')
await dayCell.click({
position: {

View file

@ -130,7 +130,10 @@ test.describe('Municípios — jornadas por papel', () => {
await expect(page.getByText('Otimista: 5.000').nth(0)).toBeVisible()
// B145: assessoria left the detail hero; it still appears on the dossiê tab.
// `.first()`: transient RSC-pending duplication also duplicates this line
// (same strict-mode flake as the "Otimista" assert above).
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=dossie`)
// `.nth(0)`: same transient hydration duplicate the "Otimista: 5.000"
// assertion pins — the server-composed slot briefly renders twice.
await expect(page.getByText(`Assessoria: ${advisor.name}`).nth(0)).toBeVisible()

View file

@ -51,8 +51,10 @@ test.describe('Territórios de Identidade', () => {
await campaign.login(page, email, password)
await page.goto('/campanha/territorios')
await expect(page.locator('#ti-irece')).toBeVisible()
await expect(page.locator('#ti-velho-chico')).toBeVisible()
// `.first()`: transient RSC-pending duplication copies the rows (observed
// strict-mode flake on loaded machines; CI stays green).
await expect(page.locator('#ti-irece').first()).toBeVisible()
await expect(page.locator('#ti-velho-chico').first()).toBeVisible()
})
test('a wide panel surfaces the read-only network columns; Cobertura stays hidden by default', async ({

View file

@ -85,7 +85,9 @@ test('enrolls a passkey, signs in with it, then revokes it', async ({
await page.getByRole('button', { name: 'Ativar neste aparelho' }).click()
await expect(page.getByText('Celular da assessora')).toBeVisible({ timeout: CEREMONY_TIMEOUT })
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeHidden()
// `.first()`: a transient RSC-pending duplication copies this empty-state
// line (observed strict-mode flake on loaded machines; CI stays green).
await expect(page.getByText('Nenhum aparelho cadastrado ainda.').first()).toBeHidden()
// 2. Password-less sign-in issues the 14-day cookie — enrolling the device IS
// the "remember me" opt-in, so there is no checkbox in this path.
@ -106,7 +108,7 @@ test('enrolls a passkey, signs in with it, then revokes it', async ({
// the profile and it has to actually stop the credential.
await page.goto('/campanha/perfil')
await page.getByRole('button', { name: 'Remover Celular da assessora' }).click()
await expect(page.getByText('Nenhum aparelho cadastrado ainda.')).toBeVisible()
await expect(page.getByText('Nenhum aparelho cadastrado ainda.').first()).toBeVisible()
await context.clearCookies()
await page.goto('/campanha/login')

View file

@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest'
import {
applyActivityAgendaOmniboxSuggestion,
buildActivityAgendaOmniboxChips,
buildActivityAgendaOmniboxSuggestionSeeds,
clearActivityAgendaOmnibox,
filterActivityAgendaOmniboxSuggestions,
removeActivityAgendaOmniboxChip,
} from '@/utilities/activityAgendaOmnibox'
const municipalityLabelsById = new Map<number, string>([
[12, 'Salvador'],
[34, 'Feira de Santana'],
])
const municipalityOptions = [
{ value: '12', label: 'Salvador' },
{ value: '34', label: 'Feira de Santana' },
]
describe('agenda omnibox adapter (C94)', () => {
it('builds removable chips for the three dimensions', () => {
const chips = buildActivityAgendaOmniboxChips({
state: { municipality: 12, tag: 'Comício', deputyPresent: true },
municipalityLabelsById,
})
expect(chips.map((chip) => chip.id)).toEqual([
'municipality:12',
'tag:Comício',
'deputyPresent',
])
expect(chips[0]!.label).toBe('Município: Salvador')
expect(chips[1]!.label).toBe('Tag: Comício')
expect(chips[2]!.label).toBe('Deputado presente')
})
it('falls back to the numeric id when a municipality label is unknown', () => {
const chips = buildActivityAgendaOmniboxChips({
state: { municipality: 99 },
municipalityLabelsById,
})
expect(chips[0]!.label).toBe('Município: Município #99')
})
it('suggests deputado presente and tags on empty query, municípios by typing', () => {
const seeds = buildActivityAgendaOmniboxSuggestionSeeds({
municipalityOptions,
knownTags: ['Comício', 'Panfletagem'],
})
const emptyQuery = filterActivityAgendaOmniboxSuggestions(seeds, '')
expect(emptyQuery.some((entry) => entry.id === 'deputyPresent')).toBe(true)
expect(emptyQuery.some((entry) => entry.id === 'tag:Comício')).toBe(true)
expect(emptyQuery.some((entry) => entry.id === 'municipality:12')).toBe(false)
const typed = filterActivityAgendaOmniboxSuggestions(seeds, 'feira')
expect(typed.some((entry) => entry.id === 'municipality:34')).toBe(true)
const grouped = typed.find((entry) => entry.id === 'municipality:34')
expect(grouped?.group).toBe('Município')
})
it('never surfaces the free-text Busca row (no text search on the agenda)', () => {
const seeds = buildActivityAgendaOmniboxSuggestionSeeds({
municipalityOptions,
knownTags: ['Comício'],
})
const suggestions = filterActivityAgendaOmniboxSuggestions(seeds, 'comí')
expect(suggestions.some((entry) => entry.id.startsWith('q:'))).toBe(false)
})
it('toggles deputado presente on and off', () => {
const on = applyActivityAgendaOmniboxSuggestion({
state: {},
suggestionId: 'deputyPresent',
})
expect(on.kind).toBe('url')
if (on.kind === 'url') expect(on.state.deputyPresent).toBe(true)
const off = applyActivityAgendaOmniboxSuggestion({
state: { deputyPresent: true },
suggestionId: 'deputyPresent',
})
expect(off.kind).toBe('url')
if (off.kind === 'url') expect(off.state.deputyPresent).toBeUndefined()
})
it('toggles tag and municipality and removes chips', () => {
const base = { municipality: 12, tag: 'Comício' }
const toggledTag = applyActivityAgendaOmniboxSuggestion({
state: base,
suggestionId: 'tag:Comício',
})
if (toggledTag.kind === 'url') expect(toggledTag.state.tag).toBeUndefined()
const toggledMunicipality = applyActivityAgendaOmniboxSuggestion({
state: base,
suggestionId: 'municipality:34',
})
if (toggledMunicipality.kind === 'url') expect(toggledMunicipality.state.municipality).toBe(34)
const removedMunicipality = removeActivityAgendaOmniboxChip({
state: { municipality: 12 },
chipId: 'municipality:12',
})
if (removedMunicipality.kind === 'url')
expect(removedMunicipality.state.municipality).toBeUndefined()
const removedDeputy = removeActivityAgendaOmniboxChip({
state: { deputyPresent: true },
chipId: 'deputyPresent',
})
if (removedDeputy.kind === 'url') expect(removedDeputy.state.deputyPresent).toBeUndefined()
})
it('ignores invalid suggestion ids and q: leftovers with a no-op url action', () => {
const state = { tag: 'Comício' }
const bogus = applyActivityAgendaOmniboxSuggestion({ state, suggestionId: 'q:whatever' })
expect(bogus.kind).toBe('url')
if (bogus.kind === 'url') expect(bogus.state.tag).toBe('Comício')
const invalid = applyActivityAgendaOmniboxSuggestion({
state,
suggestionId: 'municipality:abc',
})
expect(invalid.kind).toBe('url')
if (invalid.kind === 'url') expect(invalid.state.tag).toBe('Comício')
})
it('clears to an empty agenda state', () => {
const cleared = clearActivityAgendaOmnibox()
expect(cleared.kind).toBe('clear')
})
})

View file

@ -385,6 +385,7 @@ describe('src/utilities top-level is pinned', () => {
const pinnedTopLevel = new Set<string>([
'activityDetailPageData.ts',
'activityDetailTabUi.ts',
'activityAgendaOmnibox.ts',
'activityFormData.ts',
'activityInlineErrors.ts',
'activityOmnibox.ts',