feat(C94): agenda omnibox + feed no header/FAB

This commit is contained in:
Francisco Solla 2026-08-09 01:02:07 -03:00
parent 2270bfb84e
commit e0530a3a90
17 changed files with 1010 additions and 383 deletions

View file

@ -0,0 +1,93 @@
# Impl: C94 — Agenda: filtro combobox único, header compacto (ícones) e ações no FAB mobile
Status: rascunho
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).**
## Riscos e mitigação
- **`useSearchParams` no layout** — evitado (slot por contexto; o header não lê URL). Não introduz Suspense.
- **Flash de registro do header** — `SetCampaignHeaderAction` usa `useLayoutEffect` (padrão do título); botão aparece ~1 frame após mount; aceitável nas rotas `/campanha/agenda` (nas demais o slot fica vazio → sem poluição).
- **Contexto `openCalendarFeed` undefined antes do mount** — o drawer só abre por gesto humano, bem depois do efeito; `import-calendar` é incluído sempre na surface `list` e abre o sheet mesmo sem filtro (dialog explica), então não há tile morta.
- **Colidir com C95 (Semana ▾ no header)** — registro chaveado por `id`; C94 usa `calendar-feed`, C95 usará outra chave; ordem fixa no header.
- **Regressão e2e** — a única e2e (linhas 120-122) troca selects por interação omnibox; ajustar junto da fase 1.
- **Branch atrás de main (4 commits, C92/B171)** — rebase em `origin/main` no início da execução (skill `rebase-on-main`).
## 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,143 @@
'use client'
import { useEffect, useState } from 'react'
import { MapPinnedIcon, PlusIcon } from 'lucide-react'
import Link from 'next/link'
import { 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.
*/
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 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 update = (next: ActivityAgendaState) => {
setDraft(next)
navigate(next)
const municipalityFilterOptions = useMemo(
() =>
municipalityOptions.map((option) => ({
value: String(option.id),
label: option.name,
})),
[municipalityOptions],
)
const chips = useMemo(
() => buildActivityAgendaOmniboxChips({ state, municipalityLabelsById }),
[state, 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('')
navigate({})
return
}
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, suggestionId }))
}}
onRemoveChip={(chipId) => {
runAction(removeActivityAgendaOmniboxChip({ state, chipId }))
}}
onClearAll={() => {
runAction(clearActivityAgendaOmnibox())
}}
trailing={
<div className="hidden items-center gap-2 md:flex">
<Button
asChild
variant="outline"
size="icon"
className="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-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,72 @@
'use client'
import { CalendarIcon } from 'lucide-react'
import { useCallback, useEffect, 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])
return (
<>
<SetCampaignHeaderAction id="calendar-feed">
<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>
</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,265 @@
'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 handleRevoke = async (feedId: number) => {
await onRevokeFeed(feedId)
}
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={() => 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">
{!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

@ -3,16 +3,22 @@
import 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.values(headerActions)}
{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
@ -54,6 +57,12 @@ const OverlayActionsChrome = ({
icon: action.icon,
description: action.description,
href: action.href,
onClick: action.onAction
? () => {
action.onAction?.()
onActionClick()
}
: undefined,
}))}
className="w-full"
/>
@ -70,15 +79,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 +100,10 @@ const CampaignQuickActionsOverlayBody = ({
const CampaignQuickActionsOverlayContent = ({
actions,
onActionClick,
}: {
actions: readonly CampaignQuickAction[]
onActionClick: () => void
}) => {
const pathname = usePathname()
const { context } = useCampaignQuickActionContext()
@ -98,7 +111,7 @@ const CampaignQuickActionsOverlayContent = ({
return (
<HomeSearchExcludeProvider value={excludeContext}>
<CampaignQuickActionsOverlayBody actions={actions} />
<CampaignQuickActionsOverlayBody actions={actions} onActionClick={onActionClick} />
</HomeSearchExcludeProvider>
)
}
@ -135,7 +148,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 +175,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' }
export 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,9 +117,19 @@ 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.click()
await omniboxInput.fill(municipality.name!)
await page.getByRole('option', { name: municipality.name, exact: true }).click()
await omniboxInput.click()
await omniboxInput.fill('Comício')
await page.getByRole('option', { name: 'Comício', exact: true }).click()
await omniboxInput.click()
await page.getByRole('option', { name: 'Deputado presente', exact: true }).click()
await expect(page).toHaveURL(
`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}&deputyPresent=1&tag=Com%C3%ADcio`,
)

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',