Merge pull request #489 from fsolla/C95-agenda-seletor-de-modo-de-visualizacao-no-header-do-app

C95 — Agenda: seletor de modo de visualização no header do app
This commit is contained in:
Francisco Solla 2026-08-09 13:09:49 -03:00 committed by GitHub
commit de2e807ee0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 525 additions and 27 deletions

View file

@ -0,0 +1,120 @@
# Impl: C95 — Agenda: seletor de modo de visualização no header do app
Status: aprovado
Atualizado em: 2026-08-09
Issue: #439
Intenção: docs/plans/c95-agenda-seletor-modo-visualizacao.md
Appetite restante: ~0,5–1 dia eng (herdado); sem ajuste
## Leitura da intenção
- **Outcome:** um seletor único de modo de visualização (Dia/Semana/Mês/Lista) no **header do app** — `CampaignDesktopHeader` no desktop, `CampaignMobileTopBar` no mobile — agenda-contextual (só `/campanha/agenda`), compacto (não cresce `min-h-11`/`min-h-14`), substituindo os 4 botões de modo do FullCalendar (`headerToolbar.end`). A escolha persiste por sessão/reload (estado da tela, junto do filtro — que já vive na URL), respeita o recorte do filtro e **vence** a lógica responsiva de resize (semana↔dia) que hoje existe.
- **O que NÃO negociar:** navegação prev/next/"Hoje" permanece no FullCalendar; criação inline (C91) intacta; leader lockdown intacto; demais telas `/campanha` sem o seletor; header sem segunda linha e sem aumento de altura; escolha do usuário não é trocada por resize.
- **O que reavaliar (hipóteses da intenção):**
- "estado de vista eventualmente junto ao `ActivityAgendaState`/URL" → **confirmado**: `view` entra no contrato URL de `ActivityAgendaState` (mesmo mecanismo dos filtros — canônico, fail-closed, share/reload consistentes).
- "conectar o seletor ao `changeView` via `calendarRef`" → **via URL**: o seletor navega (`useCampaignListFilterNavigation`), o `ActivityAgenda` reage ao `state.view` com um effect que chama `changeView` — sem ref compartilhado entre header e página.
- A lógica responsiva de resize precisa reconciliar com a escolha do usuário — **gate simples**: com `view` na URL, o auto-switch semana↔dia desliga; sem `view`, comportamento atual permanece.
## Abordagem recomendada
```mermaid
flowchart LR
subgraph AGENDA_PAGE[AGENDA PAGE · server]
S[searchParams] --> R[resolveActivityAgendaUrl + restrict → state com view?]
end
R -->|state| VC[AgendaViewChrome · client]
VC -->|registra| H[SetCampaignHeaderAction id=agenda-view]
H -->|lê| DH[CampaignDesktopHeader · cluster direito]
H -->|lê| MB[CampaignMobileTopBar · junto ao sino]
VC -->|opções navegam| NAV[useCampaignListFilterNavigation → buildActivityAgendaHref]
NAV -->|router.replace view=X| R
R -->|novo state.view| AG[ActivityAgenda]
AG -->|effect state.view → changeView| FC[FullCalendar · headerToolbar sem end]
```
**Opções consideradas (estado da vista):** A) URL param `view` no `ActivityAgendaState` | B) contexto React compartilhado header↔página | C) localStorage/sessionStorage + estado local.
**Recomendação:** A — "edit the owner": o filtro da agenda já é estado de tela na URL (`municipality`/`deputyPresent`/`tag`); a vista é a mesma classe de estado. Ganha canonicalização/redirect fail-closed de graça (`resolveActivityAgendaUrl`), persistência reload/share consistente, e o seletor no header não precisa de bridge para o `calendarRef` — o FullCalendar reage a `state.view` como já reage a qualquer filtro.
**Rejeitadas:** B (o node de header é renderizado pelo shell, fora da árvore da página — provider por-página não o envolve; exigiria contexto no layout, vazamento de domínio de agenda para o shell); C (perde URL canônica/share; dois sources of truth; contraria "junto do filtro").
**Opções consideradas (controle do seletor):** A) Popover + botão-ghost compacto com rótulo do modo atual + ▾ | B) `NativeSelect` compacto | C) `ToggleGroup` com 4 ícones.
**Recomendação:** A — é o "Semana ▾" do esboço da intenção; adapta por superfície como o sino (`text-primary-foreground` no top bar escuro mobile vs `md:text-foreground` no header claro desktop); um único controle compacto na linha existente (não cresce o header). Opções com check do modo ativo.
**Rejeitadas:** B (usa tokens `--field-*` claros — destoa do `bg-primary` do top bar mobile; sem trigger customizável); C (4 segmentos não cabem na linha única — anti-goal explícito da intenção).
**Opções consideradas (reconciliação com o resize):** A) gate `!state.view` no auto-switch existente (com escolha → nada muda no resize; sem escolha → comportamento atual) | B) remover o auto-switch e deixar só `initialView` por viewport | C) canonicalizar o auto-switch na URL (vira escolha).
**Recomendação:** A — mudança mínima, preserva o default responsivo atual e faz a escolha vencer; "só o default muda por viewport" da intenção permanece verdadeiro.
**Rejeitadas:** B (muda comportamento de C15 além do pedido — o resize continuaria flipando hoje); C (torna o default "escolha" — depois do primeiro resize o usuário ficaria preso no modo auto-escrito; contradiz "só o default muda por viewport").
**Opções consideradas (mobile):** A) renderizar o mapa `headerActions` no `CampaignMobileTopBar` (o slot C94/C95 compartilhado passa a renderizar nos dois bars; o botão do feed já se auto-esconde com `hidden md:inline-flex`) | B) registro separado mobile-only.
**Recomendação:** A — o slot foi construído como "C94/C95 shared slot"; o feed já controla a própria visibilidade por viewport; o seletor nasce visível nos dois. Uma única fonte de registro.
**Rejeitadas:** B (duplica o mecanismo de registro que C94 entregou para exatamente isso).
**Opções consideradas (valores de URL):** A) códigos curtos `view=week|day|month|list` + mapa puro p/ FC ids | B) FC ids diretos `view=timeGridWeek`.
**Recomendação:** A — URL curta e estável (independe de internals do FullCalendar); o mapa é 3 linhas testáveis em `activityUi.ts`.
**Rejeitadas:** B (URL verbosa acoplada ao FullCalendar; renomear vista no FC vazaria para a URL).
**Decisão registrada (label do seletor sem `view`):** o label reflete o modo efetivo — `state.view` se presente, senão o default responsivo do viewport (`window.innerWidth < 640` → "Dia", senão "Semana"), medido com `matchMedia` + settle de hidratação (`useNarrowMeasured` em `hooks/use-mobile.ts`). A janela residual (viewport 640–~680 sem sidebar e 768–896 com sidebar 16rem aberta, onde o card do calendário cruza 640 antes do viewport) é cosmética e só ocorre sem escolha explícita — aceita, sem bridge header↔página para o estado efetivo do FC.
### Componentes / mudanças
- **`src/utilities/activityUi.ts`** (extender): `activityAgendaViews = ['week','day','month','list']`, `ActivityAgendaView` type, `activityAgendaViewLabels` (Semana/Dia/Mês/Lista), `activityAgendaViewFcId` (week→timeGridWeek, day→timeGridDay, month→dayGridMonth, list→listMonth), `isActivityAgendaView` guard. `parseActivityAgendaParams` ganha `view` (fail-closed: valor desconhecido → undefined → redirect canônico); `buildActivityAgendaSearchParams` serializa `view` ao final (ordem canônica `municipality,deputyPresent,tag,view`); `activityAgendaParamNames` += `view`; `restrictActivityAgendaState` repassa `view` (sem restrição de acesso — é estado de UI, não recorte de dados); `parseActivityAgendaReturnHref` preserva `view` no round-trip do create.
- **`ActivityAgendaFilters.tsx`** (1 linha): "Limpar" preserva `view` (limpa filtros, não o modo de visualização — `setDraft`/`navigate` mantêm `view` atual).
- **`AgendaViewSelector.tsx`** (novo, client, `components/campaign/activity/`): Popover compacto — trigger `Button` ghost `h-9 gap-1 px-2` com label do modo efetivo + `ChevronDown`, classes adaptativas `text-primary-foreground hover:bg-primary-foreground/10 md:text-foreground md:hover:bg-muted` (padrão do sino); `aria-label="Modo de visualização"`, `aria-expanded`/`aria-haspopup`. PopoverContent com 4 opções (`role="menu"`/`menuitemradio` + `aria-checked`, check do ativo); cada opção navega `{...state, view}` via `useCampaignListFilterNavigation` (mesmo hook dos filtros → dim do pending boundary na troca). Label fallback sem `view`: `matchMedia('(max-width: 639px)')` com settle de hidratação.
- **`AgendaViewChrome.tsx`** (novo, client, espelho de `AgendaFeedChrome`): recebe `state` da página; renderiza `<SetCampaignHeaderAction id="agenda-view">{selector memoizado}</SetCampaignHeaderAction>`. Montado na página **antes** de `<AgendaFeedChrome>` → ordem de inserção no mapa garante cluster `[Semana ▾][Link de import][Notificações][IA]` (gate da intenção).
- **`agenda/page.tsx`**: renderiza `<AgendaViewChrome state={state} />` antes do `<AgendaFeedChrome />`.
- **`CampaignMobileTopBar.tsx`** (extender): renderiza `Object.entries(headerActions)` entre título e sino (`<div className="ml-auto flex shrink-0 items-center gap-1">`), espelhando o cluster do desktop; wizard-mode inalterado (early return).
- **`ActivityAgenda.tsx`**: `headerToolbar.end` removido (fica `{ start: 'prev,next today', center: 'title' }`); `initialView={state.view ? activityAgendaViewFcId[state.view] : 'timeGridWeek'}`; effect `[state.view]` → `calendarRef.current?.getApi().changeView(...)` (skip se já é a vista atual; `datesSet` dispara reload da janela como hoje); `applyResponsiveView` só flips quando `state.view` ausente (mantém `isNarrow` para o inline create C91).
- **Migration:** sem migration (nenhuma mudança de schema).
- **Access / Consent:** nenhuma mudança — agenda segue `gate: 'staff'`; sem opt-in novo.
- **UI:** Impeccable B — encaixe no header global existente, controle compacto, sem redesign. Shape → craft → critique → polish leve; tokens `data-theme='campaign'`.
### Dados → forma (se aplicável)
- Não apresento dados novos (vista = estado de tela/navegação, mesma classe do filtro). Forma do controle: dropdown compacto (decisão A acima), espelhando o padrão visual do cluster direito do header.
## Fases verificáveis
1. **Contrato URL (traço dominante)** — `activityUi.ts` (views/labels/mapa/parse/serialize/restrict/returnHref) + unit tests em `activityUi.unit.spec.ts` (parse fail-closed, serialização canônica com `view`, restrict repassa, returnHref preserva, mapa FC). `ActivityAgendaFilters` "Limpar" preserva view. Verificar: URLs canônicas existentes inalteradas para estados sem `view`.
2. **Seletor + wiring do header** — `AgendaViewSelector` (Popover compacto + navegação), `AgendaViewChrome` (registro), página (ordem), `CampaignMobileTopBar` (slot no mobile); `ActivityAgenda` (headerToolbar sem `end`, initialView, effect changeView, gate do responsive).
3. **Gates** — e2e da agenda atualizado (os asserts `getByRole('tab', /semana|mês|lista/i)` morrem com a remoção do `end` — reescrever para o seletor do header, desktop e mobile); `pnpm gate:fast` na iteração; `pnpm push` no fechamento.
## Rabbit holes / Não escopo (engenharia)
- Mover prev/next/"Hoje" para o header (fica no FullCalendar — corte da intenção).
- Persistência entre dispositivos/contas (só URL/tela).
- Bridge de estado efetivo do FC para o header (label fallback usa viewport; janela cosmética aceita).
- Extrair `useNarrow` compartilhado calendário/seletor (2 call sites com alvos de medição diferentes; DRY <3).
- Redesign do FullCalendar (C15) e do slot de header (C94).
## Riscos e mitigação
- **E2E:** os asserts de `role="tab"` do FullCalendar quebram ao remover `end` — reescrever para o seletor (label `Modo de visualização` + opções), cobrindo desktop e mobile, e adicionar: troca de vista → URL `view=...` → reload persiste → escolha vence resize.
- **`useCampaignListFilterNavigation` no header:** o node renderiza no header, dentro de `CampaignListPendingBoundary` do layout (verificado) — transição compartilhada funciona; `router.replace` com `scroll: false`.
- **`SetCampaignHeaderAction` re-registra em re-render:** node memoizado (`useMemo` em `[state]`), padrão C94.
- **Eventos ao trocar vista:** `changeView` → `datesSet` → `visibleRange` muda → reload da janela (mesmo caminho dos botões atuais do FC; sem regressão).
- **Estado com `view` na action do servidor:** `loadActivityAgendaEvents({...state})` ganha `view` — zod `activityAgendaRequestSchema` (non-strict) descarta chaves desconhecidas; sem mudança de schema (verificado).
- **Flake pré-existente (`gridcell.nth(1)`):** os testes de slot do calendário (`cria inline`, `Mais detalhes`) flakavam com "Element is not attached to the DOM" no `scrollIntoViewIfNeeded` — o grid do FullCalendar pinta vazio e **substitui os gridcells quando os events chegam**, então o scroll corria contra o re-render. Estabilizado nesta entrega: esperar `Carregando compromissos…` sumir (`toHaveCount(0)`) antes de interagir nos 2 testes de slot (mesmo padrão de waits do C94, agora antes da ação). Suíte `campaignActivity.e2e.spec.ts` completa verde (6/6) com o rebase em main. O QueryError `endAt cannot be queried` apareceu 1×/9 runs (intermitente, em run sem relação com os testes que falharam; não reproduzido após o rebase).
## Pós-/simplify (reviewers paralelos)
- **P1 corrigido (desync URL→calendário):** o effect de `changeView` só sincronizava quando `state.view` estava presente; navegar para `/campanha/agenda` sem query (link da sidebar, back/forward) deixava o FullCalendar numa vista órfã (ex: mês) que o seletor não reivindicava. Agora o effect normaliza também na ausência: volta ao default responsivo (narrow → `timeGridDay`, senão `timeGridWeek`) — mesma regra do label do seletor. E2E cobre o caso (goto sem `view` → "Dia" + grid de dia).
- **P1 corrigido (e2e só provava o label):** os asserts do teste C95 agora verificam o calendário de fato — o `aria-label` do botão "Hoje" ("Esta semana"/"Este mês"/"Hoje" — o FullCalendar v7 classic hasheia as classes de view) + presença/ausência de `[role="grid"]` (month tem grid, list não). Coberto após troca, após reload, após resize (escolha vence) e após navegação sem `view`.
- **P2 corrigido (teclado do menu):** `nav[role="menu"]` ganhou `onKeyDown` com ArrowUp/ArrowDown/ArrowRight/ArrowLeft/Home/End movendo o foco entre os `menuitemradio` (roving focus mínimo; Escape/foco de volta já vêm do Radix Popover).
- **P2 corrigido (3ª cópia do hook de viewport):** `use-mobile.ts` ganhou `useNarrowMeasured(breakpoint)` (mesmo settle de hidratação de `useIsMobileMeasured`); o seletor usa `useNarrowMeasured(640)` em vez de reimplementar matchMedia. `MOBILE_BREAKPOINT` voltou a ser const interna (knip).
- **P2 corrigido (labels sem assert unit):** `activityAgendaViewLabels` pinado em unit test (typo "Mêes" falharia); parse de `view=day|list` coberto.
- **P3 (banda de divergência):** corrigida a estimativa no doc — com sidebar de 16rem a janela real é 768–896px (não 640–680).
- **P3 (Limpar sem cobertura):** e2e do "abre a semana" ganhou assert — após "Limpar", a URL mantém `view=list` (filtros vão, vista fica).
## Débitos deferidos (gatilho de revisitação)
| Débito | Gatilho |
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `menuitemradio` sem typeahead; anchor híbrido mantém ctrl/middle-click abrindo href (comportamento de link, não de radio) | Próxima Issue que tocar a11y do menu do header |
| Sem caminho de UI para "voltar ao modo automático" (uma vez escolhida, `view` fica na URL; "Limpar" preserva de propósito) | Se produto pedir default responsivo restaurado por um gesto |
| `useNarrowViewport` do seletor (viewport 640) vs `applyResponsiveView` do calendário (container 640) — banda residual documentada (768–896 com sidebar) | Se o seletor e o calendário precisarem concordar sempre (mudar o probe do calendário para viewport) |
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (seletor único no header desktop+mobile, agenda-contextual, compacto; botões FC removidos; persistência junto do filtro; escolha vence resize; sem regressão de navegação/inline/lockdown)
- [ ] Invariantes AGENTS/engineering-standards (URL canônica, staff gate, sem Consent novo, sem migration)
- [ ] Testes de domínio: unit do contrato URL (`view` parse/serialize/restrict/returnHref/mapa); e2e da agenda reescrito no seletor + persistência + escolha-vence-resize
- [ ] `pnpm gate:fast`, tsc, lint, prettier, knip, check:cycles verdes

View file

@ -10,6 +10,7 @@ import {
import { ActivityAgenda } from '@/components/campaign/activity/ActivityAgenda'
import { ActivityAgendaFilters } from '@/components/campaign/activity/ActivityAgendaFilters'
import { AgendaFeedChrome } from '@/components/campaign/activity/AgendaFeedChrome'
import { AgendaViewChrome } from '@/components/campaign/activity/AgendaViewChrome'
import {
CampaignListPendingBoundary,
CampaignListResults,
@ -55,6 +56,9 @@ export default async function AgendaPage({ searchParams }: AgendaPageProps) {
return (
<CampaignPageShell className="gap-6">
{/* C95: mounted before AgendaFeedChrome so the header cluster orders
[Semana ▾][Link de import][Notificações][IA] (gate da intenção). */}
<AgendaViewChrome state={state} />
<AgendaFeedChrome
feeds={feeds.map((f) => ({
id: f.id,

View file

@ -36,7 +36,11 @@ import {
activityStatusLabels,
type ActivityStatus,
} from '@/lib/schemas/activity'
import { activitySlotPrefill, type ActivityAgendaState } from '@/utilities/activityUi'
import {
activityAgendaViewFcId,
activitySlotPrefill,
type ActivityAgendaState,
} from '@/utilities/activityUi'
import type { ActivityAgendaEvent } from '@/utilities/activityViewModels'
import './ActivityAgenda.css'
@ -140,6 +144,9 @@ export const ActivityAgenda = ({
narrowRef.current = isNarrow
setIsNarrow((current) => (current === isNarrow ? current : isNarrow))
if (previous !== null && isNarrow === previous) return
// C95 — an explicit view in the URL wins over the responsive week↔day
// fallback; without one the narrow default keeps working as before.
if (state.view) return
const viewType = api.view.type
if (isNarrow && viewType === 'timeGridWeek') {
api.changeView('timeGridDay')
@ -153,7 +160,24 @@ export const ActivityAgenda = ({
const observer = new ResizeObserver(applyResponsiveView)
observer.observe(container)
return () => observer.disconnect()
}, [])
}, [state.view])
useEffect(() => {
const api = calendarRef.current?.getApi()
if (!api) return
if (state.view) {
const fcId = activityAgendaViewFcId[state.view]
if (api.view.type !== fcId) api.changeView(fcId)
return
}
// The URL lost the explicit view (sidebar link, back/forward): return to
// the responsive default so the calendar never keeps an orphan view (e.g.
// month) the header selector no longer claims.
const isNarrow =
(containerRef.current?.getBoundingClientRect().width ?? 0) < MOBILE_BREAKPOINT_PX
const fallback = isNarrow ? 'timeGridDay' : 'timeGridWeek'
if (api.view.type !== fallback) api.changeView(fallback)
}, [state.view])
useEffect(() => {
if (!visibleRange) return
@ -303,11 +327,10 @@ export const ActivityAgenda = ({
locale={ptBrLocale}
timeZone="America/Bahia"
firstDay={1}
initialView="timeGridWeek"
initialView={state.view ? activityAgendaViewFcId[state.view] : 'timeGridWeek'}
headerToolbar={{
start: 'prev,next today',
center: 'title',
end: 'timeGridWeek,timeGridDay,dayGridMonth,listMonth',
}}
height="auto"
slotMinTime="07:00:00"

View file

@ -92,9 +92,12 @@ export const ActivityAgendaFilters = ({
const runAction = (action: ActivityAgendaOmniboxAction) => {
if (action.kind === 'clear') {
// C95 — "Limpar" clears the recorte, not the view mode: the view is
// screen state alongside the filter, so it survives the clear.
const next: ActivityAgendaState = draft.view ? { view: draft.view } : {}
setQuery('')
setDraft({})
navigate({})
setDraft(next)
navigate(next)
return
}
setDraft(action.state)

View file

@ -0,0 +1,19 @@
'use client'
import { useMemo } from 'react'
import { AgendaViewSelector } from '@/components/campaign/activity/AgendaViewSelector'
import { SetCampaignHeaderAction } from '@/components/campaign/shell/CampaignPageChromeContext'
import type { ActivityAgendaState } from '@/utilities/activityUi'
/**
* C95 — agenda page → app chrome bridge for the view-mode selector: registers
* the compact dropdown into the app header (desktop cluster + mobile top bar)
* via the C94/C95 shared header-action slot. Mounted before `AgendaFeedChrome`
* so the cluster renders `[Semana ▾][Link de import][Notificações][IA]`.
*/
export const AgendaViewChrome = ({ state }: { state: ActivityAgendaState }) => {
const selector = useMemo(() => <AgendaViewSelector state={state} />, [state])
return <SetCampaignHeaderAction id="agenda-view">{selector}</SetCampaignHeaderAction>
}

View file

@ -0,0 +1,120 @@
'use client'
import { CheckIcon, ChevronDownIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { useNarrowMeasured } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import {
activityAgendaViewLabels,
activityAgendaViews,
buildActivityAgendaHref,
type ActivityAgendaState,
type ActivityAgendaView,
} from '@/utilities/activityUi'
/**
* Mirrors the calendar's own mobile fallback (`MOBILE_BREAKPOINT_PX` in
* `ActivityAgenda`): the selector's fallback label must agree with the view the
* calendar will pick on a narrow screen.
*/
const NARROW_VIEWPORT_PX = 640
/**
* C95 — the agenda-contextual view-mode selector rendered inside the app
* header (desktop cluster / mobile top bar). Registered by `AgendaViewChrome`,
* so it lives outside the page tree but navigates with the page's own URL
* contract (`buildActivityAgendaHref`) and the shared list transition.
*/
export const AgendaViewSelector = ({ state }: { state: ActivityAgendaState }) => {
const [open, setOpen] = useState(false)
const { isNarrow, measured } = useNarrowMeasured(NARROW_VIEWPORT_PX)
const effectiveView: ActivityAgendaView = state.view ?? (measured && isNarrow ? 'day' : 'week')
const options = useMemo(
() =>
activityAgendaViews.map((view) => ({
view,
href: buildActivityAgendaHref({ ...state, view }),
})),
[state],
)
const moveFocus = (event: React.KeyboardEvent<HTMLElement>, fromIndex: number) => {
const direction =
event.key === 'ArrowDown' || event.key === 'ArrowRight' ? 1 : event.key === 'Home' ? -2 : 0
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
)
if (items.length === 0) return
let next: number
if (event.key === 'Home') next = 0
else if (event.key === 'End') next = items.length - 1
else if (direction !== 0) next = (fromIndex + direction + items.length) % items.length
else return
items[next]?.focus()
event.preventDefault()
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={`Modo de visualização: ${activityAgendaViewLabels[effectiveView]}`}
aria-expanded={open}
aria-haspopup="menu"
className={cn(
'inline-flex h-9 shrink-0 items-center gap-1 rounded-md px-2 text-sm font-medium',
'text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground',
'md:text-foreground md:hover:bg-muted md:hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
{activityAgendaViewLabels[effectiveView]}
<ChevronDownIcon className="size-4" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1.5">
<nav
role="menu"
aria-label="Modo de visualização"
className="flex flex-col gap-0.5"
onKeyDown={(event) => {
const current = event.currentTarget.querySelector<HTMLElement>(
'[role="menuitemradio"]:focus, [role="menuitemradio"][aria-checked="true"]',
)
const index = Array.from(
event.currentTarget.querySelectorAll('[role="menuitemradio"]'),
).indexOf(current as HTMLElement)
moveFocus(event, index >= 0 ? index : 0)
}}
>
{options.map(({ view, href }) => {
const selected = view === effectiveView
return (
<CampaignTransitionAnchor
key={view}
href={href}
replace
scroll={false}
role="menuitemradio"
aria-checked={selected}
className={cn(
'flex min-h-10 items-center gap-2 rounded-md px-2 text-sm hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected && 'bg-muted font-medium',
)}
onNavigate={() => setOpen(false)}
>
<span className="flex-1">{activityAgendaViewLabels[view]}</span>
{selected ? <CheckIcon className="size-4" aria-hidden="true" /> : null}
</CampaignTransitionAnchor>
)
})}
</nav>
</PopoverContent>
</Popover>
)
}

View file

@ -1,11 +1,12 @@
'use client'
import { ArrowLeft, X } from 'lucide-react'
import type { ReactNode } from 'react'
import { Fragment, type ReactNode } from 'react'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { useCampaignHomeSearchChrome } from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
import { useCampaignHeaderActions } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { Button } from '@/components/ui/button'
@ -31,6 +32,7 @@ const wizardTitleSkeletonClass = 'mx-auto motion-reduce:animate-none bg-primary-
export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?: ReactNode }) => {
const chrome = useCampaignWizardChrome()
const homeSearchChrome = useCampaignHomeSearchChrome()
const headerActions = useCampaignHeaderActions()
const { isPending } = useCampaignListTransition()
if (chrome) {
@ -130,7 +132,12 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?:
<SidebarTrigger className="text-primary-foreground" />
)}
<CampaignPageChromeDisplay layout="mobile" />
{notificationBell ? <div className="ml-auto shrink-0">{notificationBell}</div> : null}
<div className="ml-auto flex shrink-0 items-center gap-1">
{Object.entries(headerActions).map(([id, node]) => (
<Fragment key={id}>{node}</Fragment>
))}
{notificationBell ?? null}
</div>
</header>
)
}

View file

@ -2,24 +2,24 @@ import * as React from 'react'
const MOBILE_BREAKPOINT = 768
function useMobileMeasured(): boolean | undefined {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
function useViewportNarrowMeasured(breakpoint: number): boolean | undefined {
const [isNarrow, setIsNarrow] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
setIsNarrow(window.innerWidth < breakpoint)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
setIsNarrow(window.innerWidth < breakpoint)
return () => mql.removeEventListener('change', onChange)
}, [])
}, [breakpoint])
return isMobile
return isNarrow
}
export function useIsMobile() {
return !!useMobileMeasured()
return !!useViewportNarrowMeasured(MOBILE_BREAKPOINT)
}
/**
@ -29,6 +29,16 @@ export function useIsMobile() {
* migration logic (B167) — the hydration settle must not be treated as a resize.
*/
export function useIsMobileMeasured(): { isMobile: boolean; measured: boolean } {
const isMobile = useMobileMeasured()
const isMobile = useViewportNarrowMeasured(MOBILE_BREAKPOINT)
return { isMobile: !!isMobile, measured: isMobile !== undefined }
}
/**
* Generic viewport-narrow signal with the same hydration settle as
* `useIsMobileMeasured`, at a caller-chosen breakpoint (C95 — the agenda view
* selector mirrors the calendar's own narrow threshold).
*/
export function useNarrowMeasured(breakpoint: number): { isNarrow: boolean; measured: boolean } {
const isNarrow = useViewportNarrowMeasured(breakpoint)
return { isNarrow: !!isNarrow, measured: isNarrow !== undefined }
}

View file

@ -42,13 +42,39 @@ export type ActivityListState = {
type RawSearchParams = CampaignListRawSearchParams
/**
* C95 — the agenda view modes behind the header selector. Short URL values map
* to FullCalendar view ids via `activityAgendaViewFcId` (deliberately decoupled
* from the calendar's internals).
*/
export const activityAgendaViews = ['week', 'day', 'month', 'list'] as const
export type ActivityAgendaView = (typeof activityAgendaViews)[number]
export const activityAgendaViewLabels: Record<ActivityAgendaView, string> = {
week: 'Semana',
day: 'Dia',
month: 'Mês',
list: 'Lista',
}
export const activityAgendaViewFcId: Record<ActivityAgendaView, string> = {
week: 'timeGridWeek',
day: 'timeGridDay',
month: 'dayGridMonth',
list: 'listMonth',
}
const isActivityAgendaView = (value: string | undefined): value is ActivityAgendaView =>
activityAgendaViews.includes(value as ActivityAgendaView)
export type ActivityAgendaState = {
municipality?: number
deputyPresent?: true
tag?: string
view?: ActivityAgendaView
}
const activityAgendaParamNames = ['municipality', 'deputyPresent', 'tag'] as const
const activityAgendaParamNames = ['municipality', 'deputyPresent', 'tag', 'view'] as const
const activityAgendaParamNameSet = new Set<string>(activityAgendaParamNames)
const activityTag = (value: string | undefined): string | undefined => {
@ -60,11 +86,14 @@ export const parseActivityAgendaParams = (params: RawSearchParams): ActivityAgen
const municipality = strictDecimalInteger(firstValue(params.municipality))
const deputyPresent = firstValue(params.deputyPresent) === '1' ? true : undefined
const tag = activityTag(firstValue(params.tag))
const rawView = firstValue(params.view)
const view = isActivityAgendaView(rawView) ? rawView : undefined
return {
...(municipality ? { municipality } : {}),
...(deputyPresent ? { deputyPresent } : {}),
...(tag ? { tag } : {}),
...(view ? { view } : {}),
}
}
@ -73,6 +102,7 @@ export const buildActivityAgendaSearchParams = (state: ActivityAgendaState): URL
municipality: state.municipality === undefined ? undefined : String(state.municipality),
deputyPresent: state.deputyPresent ? '1' : undefined,
tag: state.tag,
view: state.view,
})
const params = new URLSearchParams()
@ -81,6 +111,7 @@ export const buildActivityAgendaSearchParams = (state: ActivityAgendaState): URL
}
if (canonicalState.deputyPresent) params.set('deputyPresent', '1')
if (canonicalState.tag) params.set('tag', canonicalState.tag)
if (canonicalState.view) params.set('view', canonicalState.view)
return params
}
@ -100,6 +131,7 @@ export const restrictActivityAgendaState = (
: {}),
...(state.deputyPresent ? { deputyPresent: true } : {}),
...(state.tag && accessibleTags.has(state.tag) ? { tag: state.tag } : {}),
...(state.view ? { view: state.view } : {}),
})
export const resolveActivityAgendaUrl = (
@ -213,6 +245,7 @@ export const parseActivityAgendaReturnHref = (
municipality: returnUrl.searchParams.get('municipality') ?? undefined,
deputyPresent: returnUrl.searchParams.get('deputyPresent') ?? undefined,
tag: returnUrl.searchParams.get('tag') ?? undefined,
view: returnUrl.searchParams.get('view') ?? undefined,
}),
accessibleMunicipalityIDs,
accessibleTags,

View file

@ -92,9 +92,11 @@ test.describe('Agenda — calendário operacional', () => {
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible()
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('tab', { name: /semana/i })).toBeVisible()
await expect(page.getByRole('tab', { name: /mês/i })).toBeVisible()
await expect(page.getByRole('tab', { name: /lista/i })).toBeVisible()
// C95 — the four view-mode buttons moved out of the FullCalendar toolbar
// into the header selector; the calendar toolbar keeps only prev/today/next.
await expect(page.getByRole('tab', { name: /semana|mês|lista/i })).toHaveCount(0)
const viewSelector = page.getByRole('button', { name: 'Modo de visualização: Semana' })
await expect(viewSelector).toBeVisible()
const eventLink = page.getByRole('link', { name: new RegExp(title) })
const eventBox = await eventLink.boundingBox()
@ -143,8 +145,20 @@ test.describe('Agenda — calendário operacional', () => {
.filter({ visible: true })
.click()
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('tab', { name: /lista/i }).click()
// C95 — the view-mode control lives in the app top bar on mobile too; the
// narrow fallback (no `view` param) shows "Dia" after the resize.
const mobileViewSelector = page
.getByRole('button', { name: /Modo de visualização/ })
.filter({ visible: true })
await expect(mobileViewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Dia')
await mobileViewSelector.click()
await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click()
await expect(page).toHaveURL(/view=list/)
await expect(page.getByText(title, { exact: true })).toBeVisible()
// C95 — "Limpar" clears the recorte (filters), never the view mode.
await page.getByRole('button', { name: 'Limpar', exact: true }).click()
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=list`)
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true)
@ -167,13 +181,15 @@ test.describe('Agenda — calendário operacional', () => {
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
const dayCell = page.getByRole('gridcell').nth(1)
await dayCell.scrollIntoViewIfNeeded()
// 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.
// FullCalendar renders the time axis lazily and re-lays out on mount: the
// grid first paints empty and re-renders (replacing its gridcells) when the
// events land. Wait for the load to finish before interacting, or the
// locator's element is replaced mid-action ("Element is not attached").
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
await expect(slotLocator).toBeVisible()
await expect(dayCell).toBeVisible()
await dayCell.scrollIntoViewIfNeeded()
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.')
@ -219,10 +235,15 @@ test.describe('Agenda — calendário operacional', () => {
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
const dayCell = page.getByRole('gridcell').nth(1)
await dayCell.scrollIntoViewIfNeeded()
// FullCalendar renders the time axis lazily and re-lays out on mount: the
// grid first paints empty and re-renders (replacing its gridcells) when the
// events land. Wait for the load to finish before interacting, or the
// locator's element is replaced mid-action ("Element is not attached").
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
await expect(slotLocator).toBeVisible()
await expect(dayCell).toBeVisible()
await dayCell.scrollIntoViewIfNeeded()
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.')
@ -250,4 +271,71 @@ test.describe('Agenda — calendário operacional', () => {
if (!startAt || !endAt) throw new Error('O formulário não recebeu o intervalo do slot.')
expect(new Date(endAt).getTime() - new Date(startAt).getTime()).toBe(1_800_000)
})
test('C95 — seletor de vista no header troca o modo, persiste e vence o resize', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const viewSelector = page
.getByRole('button', { name: /Modo de visualização/ })
.filter({ visible: true })
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Semana')
// Desktop: switch to month; the URL gains `view=month` and the calendar
// actually renders the month grid (not just the selector label). The
// FullCalendar v7 classic theme hashes its view classes, so the stable
// signal is the "Hoje" button's aria-label ("Este mês") plus a grid.
// (`.first()`: FC keeps stale view grids in the DOM while transitioning.)
await viewSelector.click()
await page.getByRole('menuitemradio', { name: 'Mês', exact: true }).click()
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=month`)
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês')
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('grid').first()).toBeVisible()
// Persists across a reload (screen state lives beside the filter, on the URL).
await page.reload()
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=month`)
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês')
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
// The explicit choice wins over the responsive narrow fallback: shrinking
// the viewport must NOT push the calendar back to day/week.
// B167: a chat that opened by itself on the desktop panel (RRP settle)
// migrates to the full-screen mobile drawer and covers the page — close it
// first so the top bar selector stays reachable in the a11y tree.
await page
.getByRole('button', { name: 'Fechar', exact: true })
.filter({ visible: true })
.click()
await page.setViewportSize({ width: 390, height: 844 })
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês')
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
// The mobile top bar selector still switches modes. The list view shares
// the "Este mês" today label but drops the calendar grid.
await viewSelector.click()
await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click()
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=list`)
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Lista')
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('grid')).toHaveCount(0, { timeout: 15_000 })
// A navigation that drops the `view` param returns the calendar to the
// responsive default instead of keeping an orphan view the selector no
// longer claims (sidebar link / back-forward). Still on the 390px
// viewport, so the narrow default is day ("Hoje" today label, grid back).
await page.goto(`${campaign.baseURL}/campanha/agenda`)
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Dia')
await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toBeVisible({
timeout: 15_000,
})
await expect(page.getByRole('grid').first()).toBeVisible()
})
})

View file

@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import {
activityAgendaViewFcId,
activityAgendaViewLabels,
activityAgendaViews,
activitySlotPrefill,
activityTabs,
buildActivityAgendaHref,
@ -205,6 +208,74 @@ describe('activity agenda URL contract', () => {
})
})
describe('activity agenda view modes (C95)', () => {
it('parses only the declared view codes fail-closed', () => {
expect(parseActivityAgendaParams({ view: 'month' }).view).toBe('month')
expect(parseActivityAgendaParams({ view: 'week' }).view).toBe('week')
expect(parseActivityAgendaParams({ view: 'day' }).view).toBe('day')
expect(parseActivityAgendaParams({ view: 'list' }).view).toBe('list')
expect(parseActivityAgendaParams({ view: 'bogus' }).view).toBeUndefined()
expect(parseActivityAgendaParams({ view: 'timeGridMonth' }).view).toBeUndefined()
})
it('maps every declared view code to a FullCalendar view id', () => {
expect(activityAgendaViews).toEqual(['week', 'day', 'month', 'list'])
expect(activityAgendaViewFcId).toEqual({
week: 'timeGridWeek',
day: 'timeGridDay',
month: 'dayGridMonth',
list: 'listMonth',
})
})
it('labels every declared view mode in pt-BR for the header selector', () => {
expect(activityAgendaViewLabels).toEqual({
week: 'Semana',
day: 'Dia',
month: 'Mês',
list: 'Lista',
})
})
it('serializes the view last in the canonical agenda query', () => {
expect(
buildActivityAgendaSearchParams({
municipality: 12,
deputyPresent: true,
tag: 'Comício',
view: 'month',
}).toString(),
).toBe('municipality=12&deputyPresent=1&tag=Com%C3%ADcio&view=month')
expect(buildActivityAgendaHref({ view: 'day' })).toBe('/campanha/agenda?view=day')
})
it('canonicalizes an unknown view out of the URL', () => {
expect(resolveActivityAgendaUrl({ view: 'bogus', municipality: '12' }).redirectHref).toBe(
'/campanha/agenda?municipality=12',
)
expect(resolveActivityAgendaUrl({ view: 'month' }).redirectHref).toBeUndefined()
})
it('keeps the view when restricting the agenda state', () => {
expect(
restrictActivityAgendaState({ tag: 'Fora', view: 'list' }, new Set(), new Set()),
).toEqual({ view: 'list' })
})
it('round-trips the view through the create return href', () => {
expect(
parseActivityAgendaReturnHref(
'/campanha/agenda?municipality=12&view=month',
new Set([12]),
new Set(),
),
).toBe('/campanha/agenda?municipality=12&view=month')
expect(parseActivityAgendaReturnHref('/campanha/agenda?view=bogus', new Set(), new Set())).toBe(
'/campanha/agenda',
)
})
})
describe('buildActivityAgendaWhere', () => {
it('combines visible-range overlap with municipality, deputy and tag filters', () => {
expect(