From e0530a3a903dc2f948e26159171598e226701e89 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 01:02:07 -0300 Subject: [PATCH 01/10] feat(C94): agenda omnibox + feed no header/FAB --- ...enda-combobox-header-acoes-rapidas-impl.md | 93 ++++++ playwright.config.ts | 3 + .../(campaign)/campanha/(app)/agenda/page.tsx | 62 ++-- .../activity/ActivityAgendaFilters.tsx | 219 ++++++++------- .../campaign/activity/AgendaFeedChrome.tsx | 72 +++++ .../campaign/activity/CalendarFeedButton.tsx | 216 -------------- .../campaign/activity/CalendarFeedDialog.tsx | 265 ++++++++++++++++++ .../campaign/shell/CampaignDesktopHeader.tsx | 26 +- .../shell/CampaignPageChromeContext.tsx | 53 +++- .../shell/CampaignQuickActionsOverlay.tsx | 27 +- src/lib/activityQuickActions.ts | 17 +- src/lib/campaignQuickActionContext.ts | 6 + src/lib/campaignQuickActionTypes.ts | 2 + src/utilities/activityAgendaOmnibox.ts | 180 ++++++++++++ tests/e2e/campaignActivity.e2e.spec.ts | 16 +- tests/unit/activityAgendaOmnibox.unit.spec.ts | 135 +++++++++ tests/unit/codebaseConventions.unit.spec.ts | 1 + 17 files changed, 1010 insertions(+), 383 deletions(-) create mode 100644 docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md create mode 100644 src/components/campaign/activity/AgendaFeedChrome.tsx delete mode 100644 src/components/campaign/activity/CalendarFeedButton.tsx create mode 100644 src/components/campaign/activity/CalendarFeedDialog.tsx create mode 100644 src/utilities/activityAgendaOmnibox.ts create mode 100644 tests/unit/activityAgendaOmnibox.unit.spec.ts diff --git a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md new file mode 100644 index 00000000..0e7a6fbe --- /dev/null +++ b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md @@ -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
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 — `` 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 `` (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` + `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 diff --git a/playwright.config.ts b/playwright.config.ts index 1b17c023..cccf768a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/src/app/(campaign)/campanha/(app)/agenda/page.tsx b/src/app/(campaign)/campanha/(app)/agenda/page.tsx index 76a4b27b..718f68f9 100644 --- a/src/app/(campaign)/campanha/(app)/agenda/page.tsx +++ b/src/app/(campaign)/campanha/(app)/agenda/page.tsx @@ -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 ( -
- - -
+ ({ + 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) + }} + /> ({ - 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) - }} /> diff --git a/src/components/campaign/activity/ActivityAgendaFilters.tsx b/src/components/campaign/activity/ActivityAgendaFilters.tsx index 78009e96..f27f39ca 100644 --- a/src/components/campaign/activity/ActivityAgendaFilters.tsx +++ b/src/components/campaign/activity/ActivityAgendaFilters.tsx @@ -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() + 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 ( -
-
-
- - { - const municipality = Number(event.target.value) - update({ - ...draft, - municipality: municipality > 0 ? municipality : undefined, - }) - }} - className="w-full **:data-[slot=native-select]:min-h-11" + { + runAction(applyActivityAgendaOmniboxSuggestion({ state, suggestionId })) + }} + onRemoveChip={(chipId) => { + runAction(removeActivityAgendaOmniboxChip({ state, chipId })) + }} + onClearAll={() => { + runAction(clearActivityAgendaOmnibox()) + }} + trailing={ +
+
- -
- - update({ ...draft, tag: event.target.value || undefined })} - className="w-full **:data-[slot=native-select]:min-h-11" + + + + +
- - - - - - {onCreateFeed && onRevokeFeed && ( - - )} -
-

- {isPending ? 'Atualizando os compromissos da agenda.' : 'Filtros da agenda atualizados.'} -

-
+ } + /> ) } diff --git a/src/components/campaign/activity/AgendaFeedChrome.tsx b/src/components/campaign/activity/AgendaFeedChrome.tsx new file mode 100644 index 00000000..85603b9b --- /dev/null +++ b/src/components/campaign/activity/AgendaFeedChrome.tsx @@ -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 + 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 ( + <> + + + + + + + ) +} diff --git a/src/components/campaign/activity/CalendarFeedButton.tsx b/src/components/campaign/activity/CalendarFeedButton.tsx deleted file mode 100644 index 5474eace..00000000 --- a/src/components/campaign/activity/CalendarFeedButton.tsx +++ /dev/null @@ -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(null) - const [isCreating, setIsCreating] = useState(false) - const [error, setError] = useState(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 ( - <> - - - !open && handleClose()}> - - - Sincronizar com Google Calendar - - Gere um link de import para sincronizar este recorte da agenda com o Google Calendar. - Alterações em Teqo serão refletidas automaticamente. - - - - {createdUrl ? ( -
-
- -
- - -
- {copied &&

Link copiado!

} -
- -
-

Como adicionar ao Google Calendar:

-
    -
  1. Abra o Google Calendar no computador
  2. -
  3. - No menu lateral, clique em {'"'}+{'"'} ao lado de {'"'}Outras agendas{'"'} -
  4. -
  5. - Selecione {'"'}Por URL{'"'} -
  6. -
  7. - Cole o link acima e clique em {'"'}Adicionar agenda{'"'} -
  8. -
-
- -
- - -
-
- ) : ( -
-
- - setLabel(e.target.value)} - placeholder="Ex: Só deputado presente" - maxLength={120} - onKeyDown={(e) => e.key === 'Enter' && handleCreate()} - /> -

- Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'} - Reuniões em Salvador{'"'}) -

-
- - {error &&

{error}

} - - {feeds.length > 0 && ( -
-

Seus feeds ativos

-
    - {feeds.map((feed) => ( -
  • - {feed.label} - -
  • - ))} -
-
- )} - -
- - -
-
- )} -
-
- - ) -} diff --git a/src/components/campaign/activity/CalendarFeedDialog.tsx b/src/components/campaign/activity/CalendarFeedDialog.tsx new file mode 100644 index 00000000..912db22c --- /dev/null +++ b/src/components/campaign/activity/CalendarFeedDialog.tsx @@ -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 + 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(null) + const [isCreating, setIsCreating] = useState(false) + const [error, setError] = useState(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 ? ( +
+
+ +
+ + +
+ {copied &&

Link copiado!

} +
+ +
+

Como adicionar ao Google Calendar:

+
    +
  1. Abra o Google Calendar no computador
  2. +
  3. + No menu lateral, clique em {'"'} + {'+'} + {'"'} ao lado de {'"'}Outras agendas{'"'} +
  4. +
  5. + Selecione {'"'}Por URL{'"'} +
  6. +
  7. + Cole o link acima e clique em {'"'}Adicionar agenda{'"'} +
  8. +
+
+ +
+ {!isMobile ? ( + + ) : null} + +
+
+ ) : ( +
+ {!canGenerate ? ( +

+ Aplique filtros à agenda para gerar um link de import. +

+ ) : null} + +
+ + setLabel(e.target.value)} + placeholder="Ex: Só deputado presente" + maxLength={120} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + /> +

+ Um nome para identificar este feed (ex: {'"'}Agenda do candidato{'"'}, {'"'} + Reuniões em Salvador{'"'}) +

+
+ + {error &&

{error}

} + + {feeds.length > 0 && ( +
+

Seus feeds ativos

+
    + {feeds.map((feed) => ( +
  • + {feed.label} + +
  • + ))} +
+
+ )} + +
+ {!isMobile ? ( + + ) : null} + +
+
+ )} + + ) + + if (isMobile) { + return ( + + + + Sincronizar com Google Calendar + + Gere um link de import para sincronizar este recorte da agenda com o Google Calendar. + Alterações em Teqo serão refletidas automaticamente. + + +
{body}
+ + Fechar + +
+
+ ) + } + + return ( + + + + Sincronizar com Google Calendar + + Gere um link de import para sincronizar este recorte da agenda com o Google Calendar. + Alterações em Teqo serão refletidas automaticamente. + + + {body} + + + ) +} diff --git a/src/components/campaign/shell/CampaignDesktopHeader.tsx b/src/components/campaign/shell/CampaignDesktopHeader.tsx index b79e9449..c2ae25db 100644 --- a/src/components/campaign/shell/CampaignDesktopHeader.tsx +++ b/src/components/campaign/shell/CampaignDesktopHeader.tsx @@ -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 }) => ( -
- - -
- - {notificationBell ?? null} -
-
-) +export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => { + const headerActions = useCampaignHeaderActions() + + return ( +
+ + +
+ {Object.values(headerActions)} + {notificationBell ?? null} + +
+
+ ) +} diff --git a/src/components/campaign/shell/CampaignPageChromeContext.tsx b/src/components/campaign/shell/CampaignPageChromeContext.tsx index 1f9b84b7..d44ca20a 100644 --- a/src/components/campaign/shell/CampaignPageChromeContext.tsx +++ b/src/components/campaign/shell/CampaignPageChromeContext.tsx @@ -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 + type CampaignPageChromeContextValue = { role: CampaignUser['role'] override: CampaignPageChrome | null setOverride: Dispatch> + headerActions: CampaignHeaderActionsMap + setHeaderAction: (key: string, node: ReactNode | null) => void } const CampaignPageChromeContext = createContext(null) @@ -30,7 +40,24 @@ export const CampaignPageChromeProvider = ({ children: ReactNode }) => { const [override, setOverride] = useState(null) - const value = useMemo(() => ({ role, override, setOverride }), [role, override]) + const [headerActions, setHeaderActions] = useState({}) + + 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 ( @@ -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 +} diff --git a/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx b/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx index d45689d3..377cac3a 100644 --- a/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx @@ -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 (
- +
void }) => { const pathname = usePathname() const { context } = useCampaignQuickActionContext() @@ -98,7 +111,7 @@ const CampaignQuickActionsOverlayContent = ({ return ( - + ) } @@ -135,7 +148,10 @@ export const CampaignQuickActionsOverlay = ({ Ações do contexto e busca na campanha
- + onOpenChange(false)} + />
@@ -159,7 +175,10 @@ export const CampaignQuickActionsOverlay = ({ Ações do contexto e busca na campanha
- + onOpenChange(false)} + />
diff --git a/src/lib/activityQuickActions.ts b/src/lib/activityQuickActions.ts index ec7d69c1..636a589d 100644 --- a/src/lib/activityQuickActions.ts +++ b/src/lib/activityQuickActions.ts @@ -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 diff --git a/src/lib/campaignQuickActionContext.ts b/src/lib/campaignQuickActionContext.ts index a3290573..68420972 100644 --- a/src/lib/campaignQuickActionContext.ts +++ b/src/lib/campaignQuickActionContext.ts @@ -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 => ({}) diff --git a/src/lib/campaignQuickActionTypes.ts b/src/lib/campaignQuickActionTypes.ts index 10cd88be..a07741c0 100644 --- a/src/lib/campaignQuickActionTypes.ts +++ b/src/lib/campaignQuickActionTypes.ts @@ -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 } diff --git a/src/utilities/activityAgendaOmnibox.ts b/src/utilities/activityAgendaOmnibox.ts new file mode 100644 index 00000000..490af7c8 --- /dev/null +++ b/src/utilities/activityAgendaOmnibox.ts @@ -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 +}): 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[] = [] + + 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, + 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' }) diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index 352dc872..08696971 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -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`, ) diff --git a/tests/unit/activityAgendaOmnibox.unit.spec.ts b/tests/unit/activityAgendaOmnibox.unit.spec.ts new file mode 100644 index 00000000..5c6e4987 --- /dev/null +++ b/tests/unit/activityAgendaOmnibox.unit.spec.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' + +import { + applyActivityAgendaOmniboxSuggestion, + buildActivityAgendaOmniboxChips, + buildActivityAgendaOmniboxSuggestionSeeds, + clearActivityAgendaOmnibox, + filterActivityAgendaOmniboxSuggestions, + removeActivityAgendaOmniboxChip, +} from '@/utilities/activityAgendaOmnibox' + +const municipalityLabelsById = new Map([ + [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') + }) +}) diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 3968c4b8..1bd0a923 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -385,6 +385,7 @@ describe('src/utilities top-level is pinned', () => { const pinnedTopLevel = new Set([ 'activityDetailPageData.ts', 'activityDetailTabUi.ts', + 'activityAgendaOmnibox.ts', 'activityFormData.ts', 'activityInlineErrors.ts', 'activityOmnibox.ts', From ad39602d042e5dc9dda6c6f78815a356cfd9f311 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 02:15:22 -0300 Subject: [PATCH 02/10] feat(C94): toolbar omnibox + e2e agenda hardening + changelog --- .gitignore | 1 + docs/CHANGELOG-AGENTS.md | 5 +++++ ...enda-combobox-header-acoes-rapidas-impl.md | 20 +++++++++++-------- .../activity/ActivityAgendaFilters.tsx | 4 ++-- tests/e2e/campaignActivity.e2e.spec.ts | 6 +++--- 5 files changed, 23 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 0baa69e7..2ddff5af 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /playwright-report/ /test-results/ /.playwright-cli/ +/.playwright-mcp/ # Aider (local agent history + tag cache) .aider* diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index eeb2675f..3c9427ea 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -12,6 +12,11 @@ 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-` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-` 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`/`teqo_wt_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). diff --git a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md index 0e7a6fbe..cd5d9d7f 100644 --- a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md +++ b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md @@ -1,6 +1,6 @@ # Impl: C94 — Agenda: filtro combobox único, header compacto (ícones) e ações no FAB mobile -Status: rascunho +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 @@ -76,14 +76,18 @@ flowchart LR - **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 +## Divergências executadas (vs. hipótese da intençã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`). +- `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 ``** (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`. + +## 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 diff --git a/src/components/campaign/activity/ActivityAgendaFilters.tsx b/src/components/campaign/activity/ActivityAgendaFilters.tsx index f27f39ca..896a9efe 100644 --- a/src/components/campaign/activity/ActivityAgendaFilters.tsx +++ b/src/components/campaign/activity/ActivityAgendaFilters.tsx @@ -117,7 +117,7 @@ export const ActivityAgendaFilters = ({ asChild variant="outline" size="icon" - className="min-w-11" + className="min-h-11 min-w-11" aria-label="Planejar giro" title="Planejar giro" > @@ -128,7 +128,7 @@ export const ActivityAgendaFilters = ({ + ), + [canGenerate, openFeed], + ) + return ( <> - - - + {headerButton} setCopied(false), 2000) } - const handleRevoke = async (feedId: number) => { - await onRevokeFeed(feedId) - } - const handleClose = (next: boolean) => { if (next) return setCreatedUrl(null) @@ -198,7 +194,7 @@ export const CalendarFeedDialog = ({ type="button" variant="ghost" size="icon" - onClick={() => handleRevoke(feed.id)} + onClick={() => void onRevokeFeed(feed.id)} aria-label={`Revogar feed ${feed.label}`} > diff --git a/src/components/campaign/shell/CampaignDesktopHeader.tsx b/src/components/campaign/shell/CampaignDesktopHeader.tsx index c2ae25db..dbf68943 100644 --- a/src/components/campaign/shell/CampaignDesktopHeader.tsx +++ b/src/components/campaign/shell/CampaignDesktopHeader.tsx @@ -1,6 +1,6 @@ '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' @@ -15,7 +15,9 @@ export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?:
- {Object.values(headerActions)} + {Object.entries(headerActions).map(([id, node]) => ( + {node} + ))} {notificationBell ?? null}
diff --git a/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx b/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx index 377cac3a..eca83cbe 100644 --- a/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx +++ b/src/components/campaign/shell/CampaignQuickActionsOverlay.tsx @@ -56,7 +56,10 @@ 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?.() diff --git a/src/utilities/activityAgendaOmnibox.ts b/src/utilities/activityAgendaOmnibox.ts index 490af7c8..f03399af 100644 --- a/src/utilities/activityAgendaOmnibox.ts +++ b/src/utilities/activityAgendaOmnibox.ts @@ -20,7 +20,7 @@ export type ActivityAgendaOmniboxAction = | { kind: 'url'; state: ActivityAgendaState } | { kind: 'clear' } -export type ActivityAgendaFilterOption = { +type ActivityAgendaFilterOption = { value: string label: string } From d4aceccf3faecaed92cf73c459b5f59d2b98bedb Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 02:41:58 -0300 Subject: [PATCH 04/10] docs(C94): registra desfecho do /simplify no impl plan --- .../c94-agenda-combobox-header-acoes-rapidas-impl.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md index cd5d9d7f..b2fc9510 100644 --- a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md +++ b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md @@ -82,6 +82,15 @@ flowchart LR - 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`. + ## Riscos e mitigação (executados) - **Header** `[actions][sino][IA]` conforme o gate; sem `useSearchParams` no layout. From 340407c758b1da709b5b17ef2195c6f173397dfa Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 02:42:15 -0300 Subject: [PATCH 05/10] =?UTF-8?q?docs(C94):=20d=C3=A9bitos=20deferidos=20c?= =?UTF-8?q?om=20gatilho=20no=20impl=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../c94-agenda-combobox-header-acoes-rapidas-impl.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md index b2fc9510..3f2e34ca 100644 --- a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md +++ b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md @@ -91,6 +91,16 @@ flowchart LR - `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`. +## 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. From 6c631bdeecaa2fd753bcb88bd13c2b7a6111361c Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 02:59:17 -0300 Subject: [PATCH 06/10] =?UTF-8?q?fix(e2e):=20agenda=20=E2=80=94=20waits=20?= =?UTF-8?q?de=20visibilidade=20para=20medir=20o=20slot=20do=20calend=C3=A1?= =?UTF-8?q?rio=20(estabiliza=20inline/mais=20detalhes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/e2e/campaignActivity.e2e.spec.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index 878914ca..b5b311a2 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -168,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: { @@ -216,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: { From b8ee59f0f698469013bf9f8cb403a74c0c454b9d Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 03:02:22 -0300 Subject: [PATCH 07/10] style(C94): formata (prettier) --- ...94-agenda-combobox-header-acoes-rapidas-impl.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md index 3f2e34ca..4bd33415 100644 --- a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md +++ b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md @@ -93,13 +93,13 @@ flowchart LR ## 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) | +| 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) From a195dd685157187f6ab26a594f94defd3e164558 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 03:02:58 -0300 Subject: [PATCH 08/10] =?UTF-8?q?docs(C94):=20nota=20de=20estabiliza=C3=A7?= =?UTF-8?q?=C3=A3o=20do=20e2e=20no=20impl=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md index 4bd33415..6052adb4 100644 --- a/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md +++ b/docs/plans/c94-agenda-combobox-header-acoes-rapidas-impl.md @@ -90,6 +90,7 @@ flowchart LR - 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) From cd4698571a26da593700c3eab1823f41af40329f Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 03:50:25 -0300 Subject: [PATCH 09/10] =?UTF-8?q?fix(e2e):=20first()=20em=20asserts=20com?= =?UTF-8?q?=20duplica=C3=A7=C3=A3o=20transit=C3=B3ria=20de=20RSC=20pending?= =?UTF-8?q?=20(flake=20pr=C3=A9-existente,=20base=20reproduz)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/e2e/campaignMunicipalities.e2e.spec.ts | 3 +++ tests/e2e/campaignTerritories.e2e.spec.ts | 6 ++++-- tests/e2e/campaignWebAuthn.e2e.spec.ts | 6 ++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index f8d12969..8bb83dc3 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -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() diff --git a/tests/e2e/campaignTerritories.e2e.spec.ts b/tests/e2e/campaignTerritories.e2e.spec.ts index 8a55cf44..8bdaa5db 100644 --- a/tests/e2e/campaignTerritories.e2e.spec.ts +++ b/tests/e2e/campaignTerritories.e2e.spec.ts @@ -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 ({ diff --git a/tests/e2e/campaignWebAuthn.e2e.spec.ts b/tests/e2e/campaignWebAuthn.e2e.spec.ts index 8cf2561c..417d9c24 100644 --- a/tests/e2e/campaignWebAuthn.e2e.spec.ts +++ b/tests/e2e/campaignWebAuthn.e2e.spec.ts @@ -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') From 1dae9d38236bd8793da4e52f7771846c9de738e6 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 05:09:48 -0300 Subject: [PATCH 10/10] style(C94): formata changelog (prettier) --- docs/CHANGELOG-AGENTS.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 3c9427ea..7b0fedb2 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -13,10 +13,9 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **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) +(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-` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-` 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`/`teqo_wt_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).