feat(C104): compromisso 'Todo o dia' na criação rápida da agenda (só datas, multi-dia)

This commit is contained in:
Francisco Solla 2026-08-09 22:40:06 -03:00
parent 8c79eddf96
commit ea700e3113
30 changed files with 14390 additions and 113 deletions

View file

@ -4,11 +4,9 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
--- ---
**Recently resolved (2026-08-09):** **D6** (Issue #521) — push no device fechado de ponta a ponta. **Diagnóstico com evidência por elo** (leitura read-only de prod): (1) **envs VAPID ausentes no Vercel production** (verificado com `vercel env ls production`) — elo raiz: sem elas o servidor nunca chama o web-push e o card de perfil desabilita "Ativar"; (2) consentimento `campanha-notificacoes-push` já publicado (Onda 0, 2026-07-19); (3) 0 inscrições `pushSubscription` (bloqueado pelo elo 1); (4) 74 `Notification` geradas no sino (eventos fluem) mas **zero envios**; (5) banner no OS = passo humano (Android). **Dois defeitos de código corrigidos com evidência:** (a) o SW `/campanha/sw.js` servia `CACHE_NAME "campanha-dev"` em prod — deploys são CLI `vercel build --prebuilt` (git builds ignorados), e a rota `force-static` congelava o buildId no build onde `VERCEL_GIT_COMMIT_SHA`/`VERCEL_DEPLOYMENT_ID` não existem; a rota virou **`force-dynamic`** (lê `VERCEL_DEPLOYMENT_ID`, garantido em runtime) → cache versionado por deploy (unit test pina `dynamic`); (b) **subscribe não idempotente** — segundo toque em "Ativar neste dispositivo" no mesmo device devolvia a mesma subscription do browser e estourava o UNIQUE do `endpoint` (erro falso com o device já ativo); `subscribeCampaignPush` agora faz find-before-create e responde sucesso. **Ferramenta nova:** `pnpm db:check:push-chain` (`scripts/check-push-chain.mjs`, read-only — `PROD_DATABASE_URL` obrigatória, conexões com `default_transaction_read_only`) fecha um veredito por elo (SW servido/versionado, envs, consentimento, inscrições por usuário, log de eventos) com exit code. **Ops:** par VAPID gerado e adicionado em **Production** (`VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY`/`VAPID_SUBJECT`/`NEXT_PUBLIC_VAPID_PUBLIC_KEY`); preview ficou de fora — o projeto não tem Git repo conectado (deploys CLI) e `vercel env add ... preview` exige branch (`branch_not_found`); `NEXT_PUBLIC_*` precisa do build do próximo deploy para inliner. **Pendência humana:** teste em device real (Android Chrome) após o deploy — ativar em `/campanha/perfil`, disparar evento, conferir banner + toque; `db:check:push-chain` antes/depois. Sem migration, access ou Consent (fail-closed intacto). Intenção [`push-notificacoes-dispositivo-producao.md`](plans/push-notificacoes-dispositivo-producao.md); impl [`push-notificacoes-dispositivo-producao-impl.md`](plans/push-notificacoes-dispositivo-producao-impl.md). **Recently resolved (2026-08-09):** **C104** (Issue #505) — compromissos **"Todo o dia"** na agenda: o sheet de criação rápida (C91) e o formulário completo ganharam o toggle **Todo o dia** (checkbox; no sheet entre o título e Início/Término, no form no topo do card "Data e horário") — ligado, Início e Término viram **só datas** (término pode ser outro dia = faixa de vários dias inteiros; dia único = `startAt === endAt`). Representação: campo novo `allDay` (migration `20260810_010844_add_activity_all_day`) + convenção de instantes **fim inclusivo** (startAt = 1º dia 00:00 America/Bahia, endAt = último dia 00:00; Bahia sem DST); conversões exclusivo↔inclusivo (FullCalendar/iCal usam fim exclusivo) concentradas no módulo puro `lib/activityAllDay.ts` (unit-pinado: dia único, multi-dia, virada de mês/ano, rótulo "10/08/2026 a 12/08/2026"). Calendário: `allDaySlot` ligado (faixa no topo de semana/dia), `toEventInput` com `allDay`, reschedule converte os `startStr`/`endStr` exclusivos e envia `allDay`; **guard de drop**: arrastar entre a lane all-day e a grade de horário é rejeitado (revert + toast — converter por drag é feature fora do escopo). "Mais detalhes" preserva a escolha (`allDay=1` + datas civis nos params; form nasce com toggle ligado). Rótulos all-day em card, overview, dossiê do município, cabeçalho de detalhe e home search; feed iCal exporta `DTSTART;VALUE=DATE`/`DTEND;VALUE=DATE` com fim exclusivo. Liderança não consegue flipar `allDay` (snapshot staff). Sem access/Consent. Intenção [`agenda-toggle-todo-o-dia.md`](plans/agenda-toggle-todo-o-dia.md); impl [`agenda-toggle-todo-o-dia-impl.md`](plans/agenda-toggle-todo-o-dia-impl.md).
**Recently resolved (2026-08-09):** **B183** (Issue #501) — o auto-zoom do Safari iOS em inputs do PWA `/campanha` (font < 16px ao focar, escala que não volta no standalone) foi eliminado pela **causa**: uma regra CSS única em `styles.css` — `@media (pointer: coarse) and (hover: none)` + `[data-theme='campaign'] :is(input, select, textarea, [contenteditable='true']) { font-size: 1rem }` — cobrindo omnibox B127, Command search, native-select e textareas em qualquer orientação do iPhone (landscape chega a 932px de largura, o que descartou escopo por `max-width`). Unlayered vence as utilities `text-sm` sem `!important`; desktop (pointer fine) fica intocado; `Input`/`Textarea` do ui já eram `text-base md:text-sm`. Sem migration, access, Consent ou mudança de componente. E2E novo `tests/e2e/campaignIosInputZoom.e2e.spec.ts` pina o contrato: emulação `devices['iPhone 13']` → omnibox de `/campanha/municipios` computa **16px**; control desktop → permanece **14px**. Intenção [`pwa-ios-autozoom-input-foco.md`](plans/pwa-ios-autozoom-input-foco.md); impl [`pwa-ios-autozoom-input-foco-impl.md`](plans/pwa-ios-autozoom-input-foco-impl.md). **Recently resolved (2026-08-09):** **D6** (Issue #521) — push no device fechado de ponta a ponta. **Diagnóstico com evidência por elo** (leitura read-only de prod): (1) **envs VAPID ausentes no Vercel production** (verificado com `vercel env ls production`) — elo raiz: sem elas o servidor nunca chama o web-push e o card de perfil desabilita "Ativar"; (2) consentimento `campanha-notificacoes-push` já publicado (Onda 0, 2026-07-19); (3) 0 inscrições `pushSubscription` (bloqueado pelo elo 1); (4) 74 `Notification` geradas no sino (eventos fluem) mas **zero envios**; (5) banner no OS = passo humano (Android). **Dois defeitos de código corrigidos com evidência:** (a) o SW `/campanha/sw.js` servia `CACHE_NAME "campanha-dev"` em prod — deploys são CLI `vercel build --prebuilt` (git builds ignorados), e a rota `force-static` congelava o buildId no build onde `VERCEL_GIT_COMMIT_SHA`/`VERCEL_DEPLOYMENT_ID` não existem; a rota virou **`force-dynamic`** (lê `VERCEL_DEPLOYMENT_ID`, garantido em runtime) → cache versionado por deploy (unit test pina `dynamic`); (b) **subscribe não idempotente** — segundo toque em "Ativar neste dispositivo" no mesmo device devolvia a mesma subscription do browser e estourava o UNIQUE do `endpoint` (erro falso com o device já ativo); `subscribeCampaignPush` agora faz find-before-create e responde sucesso. **Ferramenta nova:** `pnpm db:check:push-chain` (`scripts/check-push-chain.mjs`, read-only — `PROD_DATABASE_URL` obrigatória, conexões com `default_transaction_read_only`) fecha um veredito por elo (SW servido/versionado, envs, consentimento, inscrições por usuário, log de eventos) com exit code. **Ops:** par VAPID gerado e adicionado em **Production** (`VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY`/`VAPID_SUBJECT`/`NEXT_PUBLIC_VAPID_PUBLIC_KEY`); preview ficou de fora — o projeto não tem Git repo conectado (deploys CLI) e `vercel env add ... preview` exige branch (`branch_not_found`); `NEXT_PUBLIC_*` precisa do build do próximo deploy para inliner. **Pe... (line truncated to 2000 chars)
**Recently resolved (2026-08-09):** **C102** (Issue #498) — no mobile (<768px), nenhuma tela `/campanha` de staff mostra mais hamburger nem sheet de sidebar: a navegação staff mora na barra inferior + drawer "Mais". `CampaignMobileTopBar` esconde o `SidebarTrigger` via `useCampaignPageChromeRole` + `isStaffCampaignRole` (leader mantém — lockdown sem barra inferior); `CampaignSidebar` retorna `null` quando `isMobile && staff`, desmontando o Sheet; **B18 (filtros salvos de Municípios) migrou para o drawer "Mais"** — `MunicipalityNavSavedFilters` ganhou `variant: 'overflow'` (mesma store `useMunicipalitySavedFilters`, mesma lógica apagar/desfazer/sucessão de foco; no drawer o fallback de foco vira o link Perfil do footer) e `CampaignBottomNav` passou a renderizá-la entre destinos e footer (container com `overflow-y-auto`). **Bug pré-existente corrigido no caminho (D4):** o `<Toaster>` vivia dentro do wrapper `[contain:layout_paint]` do painel da AI — `contain: paint` vira containing block para `position: fixed` e cria stacking context próprio, prendendo o toast (z 999999999 do sonner) ABAIXO dos portais z-50 de drawer/sheet: qualquer toast com ação (ex. "Desfazer") era inalcançável com modal aberto; o Toaster foi movido para o root layout `(campaign)/layout.tsx` (irmão do `{children}`, direto no `<body>`). Testes: 2 unit novos na top bar (staff sem trigger / leader com trigger), 3 e2e novos (staff mobile sem trigger e sem dialog Sidebar; leader abre o sheet com "Meus contatos"; fluxo completo salvar→navegar→apagar→desfazer no drawer). Sem migration, access ou Consent. Intenção [`c102-mobile-sem-botao-sidebar.md`](plans/c102-mobile-sem-botao-sidebar.md); impl [`c102-mobile-sem-botao-sidebar-impl.md`](plans/c102-mobile-sem-botao-sidebar-impl.md).
**Recently resolved (2026-08-09):** **B179** (Issue #462) — o Sollinha passou a falar de si **no masculino**: o system prompt (`src/utilities/ai/systemPrompt.ts`) abre com "Você é **o** Sollinha" e ganhou regra explícita (auto-referência masculina em primeira pessoa, nunca flexões femininas sobre si) — o welcome da UI já era masculino; o placeholder do chat virou "Pergunte para o Sollinha..." (`CampaignAIChat.tsx`) com os seletores e2e acompanhando (2 specs). Sem migration, access, Consent ou guarda automatizada (decisão do gate: só édito de texto). Os planos entregues da família (B162/B166/B167/B173/B174/B177) permanecem frozen com "a Sollinha" histórico — o que vale é a voz no runtime e o que se escreve daqui pra frente. Intenção [`sollinha-genero-masculino.md`](plans/sollinha-genero-masculino.md); impl [`sollinha-genero-masculino-impl.md`](plans/sollinha-genero-masculino-impl.md). **Recently resolved (2026-08-09):** **B179** (Issue #462) — o Sollinha passou a falar de si **no masculino**: o system prompt (`src/utilities/ai/systemPrompt.ts`) abre com "Você é **o** Sollinha" e ganhou regra explícita (auto-referência masculina em primeira pessoa, nunca flexões femininas sobre si) — o welcome da UI já era masculino; o placeholder do chat virou "Pergunte para o Sollinha..." (`CampaignAIChat.tsx`) com os seletores e2e acompanhando (2 specs). Sem migration, access, Consent ou guarda automatizada (decisão do gate: só édito de texto). Os planos entregues da família (B162/B166/B167/B173/B174/B177) permanecem frozen com "a Sollinha" histórico — o que vale é a voz no runtime e o que se escreve daqui pra frente. Intenção [`sollinha-genero-masculino.md`](plans/sollinha-genero-masculino.md); impl [`sollinha-genero-masculino-impl.md`](plans/sollinha-genero-masculino-impl.md).

View file

@ -0,0 +1,127 @@
# Impl: Agenda — compromisso "Todo o dia" na criação rápida (só datas, multi-dia)
Status: aprovado
Atualizado em: 2026-08-09
Issue: #505
Intenção: docs/plans/agenda-toggle-todo-o-dia.md
Appetite restante: herdado (~0,5 dia eng)
## Leitura da intenção
- **Outcome:** o sheet de criação rápida ganha um toggle "Todo o dia" entre o título e a data de início; ligado, Início e Término viram só datas (término pode ser outro dia = vários dias inteiros); o compromisso aparece no calendário como faixa de dia inteiro ocupando todos os dias; "Mais detalhes" e o formulário completo preservam a escolha (e editar não quebra o evento); criar com horário continua exatamente como hoje.
- **O que NÃO negociar:** nada de semântica de timezone (granularidade de dia); horário nunca aparece com o toggle ligado; liderança não acessa a agenda (inalterado); criação com horário intacta; o toggle nasce desligado (questão A assumida — validar); término multi-dia = faixa contínua (questão C decidida no gate).
- **O que reavaliar:** a intenção deixa a representação (flag vs horários de borda) para o impl plan; e a questão B (toggle no form completo) tem recomendação "B — mesmo controle no form" — adoto B, porque um evento todo-dia editado no form sem o toggle mostraria "00:00" (regressão de leitura) e salvar com horário quebraria a faixa.
## Abordagem recomendada
```mermaid
flowchart LR
A[Toggle Todo o dia<br/>sheet + form] --> B[allDay: boolean<br/>nova coluna]
B --> C[Convenção de armazenamento<br/>startAt = 1º dia 00:00 Bahia<br/>endAt = último dia 00:00 Bahia<br/>fim inclusivo]
C --> D[lib/activityAllDay.ts<br/>puro: datas civis ↔ instantes<br/>+ fim exclusivo p/ FullCalendar/iCal]
D --> E[Agenda: toEventInput allDay<br/>+ allDaySlot ligado + drop guard]
D --> F[iCal: DTSTART/DTEND VALUE=DATE]
D --> G[Rótulos: card, overview, dossiê,<br/>home search — só data]
```
**Opções consideradas:** A (recomendada) | B | C
**Recomendação:** **A** — campo `allDay: boolean` (default `false`) + convenção de instantes: com o toggle ligado, `startAt` = primeiro dia 00:00 America/Bahia e `endAt` = último dia 00:00 America/Bahia, com **fim inclusivo** (a data do instante `endAt` É o último dia; dia único ⇒ `startAt === endAt`). As conversões exclusivo↔inclusivo (FullCalendar/iCal usam fim exclusivo) ficam concentradas num módulo puro `lib/activityAllDay.ts` — o resto do sistema lê a intenção do usuário direto dos campos.
**Rejeitadas:** B porque armazenar fim **exclusivo** (modelo nativo do FullCalendar) espalha o off-by-one por todas as superfícies que exibem Término (overview, form de edição, cards) — qualquer consumidor que esquecer de subtrair 1 dia mostra o dia errado silenciosamente; A concentra as conversões nas 2 fronteiras de calendário (agenda + iCal), ambas testadas. C (flag implícita — "allDay se hora == 00:00") porque é estado frágil, rejeita reuniões reais à meia-noite e o admin não consegue distinguir.
### Decisões de engenharia
**D1 — Representação de armazenamento.**
- Opções: A) `allDay` boolean + instantes com fim inclusivo (startAt = 1º dia 00:00, endAt = último dia 00:00 Bahia) | B) `allDay` boolean + fim exclusivo (endAt = dia seguinte 00:00) | C) flag implícita por convenção de horário | D) campos data separados (`allDayStart`/`allDayEnd`).
- Recomendação: A — ver "Rejeitadas" da abordagem; America/Bahia não tem DST (fixo −03), então "00:00 da Bahia" é determinístico; a validação allDay vira `endAt >= startAt` (igual permitido), só na branch allDay.
- Rejeitadas: B (off-by-one espalhado); C (frágil, opaco); D (gêmeo de startAt/endAt, sync obrigatória no hook — twin anti-pattern).
**D2 — Onde moram as conversões.**
- Opções: A) módulo novo puro `src/lib/activityAllDay.ts` (client-safe) | B) empilhar em `lib/campaignTime.ts` | C) conversões inline nos componentes.
- Recomendação: A — semântica all-day é de domínio da atividade, não tempo civil genérico; `lib/` é client-safe por mapa do repo, e o módulo é usado por client (agenda, sheet) e server (schema, formData, iCal) — o mesmo padrão de `lib/campaignRoles.ts`. Helpers: `allDayStartInstant(civilDate)`, `allDayEndInstant(civilDate)`, `allDayExclusiveEndDate(iso)` (data + 1 dia), `allDayEndInstantFromExclusive(dataExclusiva)` (data − 1 dia), `formatAllDayRangeLabel(startIso, endIso)` ("10/08/2026" | "10/08/2026 a 12/08/2026"), `allDayCivilDateOf(iso)`, `allDayRangeValid(startIso, endIso)`.
- Rejeitadas: B porque mistura domínio em utilitário genérico; C porque a conversão exclusivo↔inclusivo vazaria para N call sites (precisamente o bug que D1-A evita).
**D3 — Contrato do reschedule.**
- Opções: A) o client converte (FullCalendar exclusivo → convenção de storage) e envia `{ id, allDay, startAt, endAt }` com instantes prontos | B) o schema aceita strings só-data e converte no server.
- Recomendação: A — o `persistSchedule` da agenda é o ÚNICO produtor do formato exclusivo do FullCalendar; a conversão usa o módulo puro testado; o schema ganha só `allDay: z.boolean()` e a regra `start <= end` para allDay (endAt obrigatório para allDay). Rejeitada: B porque duplica a fronteira de conversão no schema sem ganho — e o server não sabe o `allDay` atual do evento sem um fetch extra.
**D4 — Guard de drop no all-day lane.**
- Opções: A) rejeitar (revert + toast) qualquer drop que altere `event.allDay` (lanes all-day ⇄ grade de horário) | B) permitir e persistir a conversão (update de `allDay` + horários).
- Recomendação: A — habilitar o `allDaySlot` (necessário para a faixa no topo da visão semana/dia) cria a lane; o FullCalendar flips `allDay` em drops entre lane e grade. Persistir a conversão é feature nova (C103-adjacent, fora do aceite) e muda o reschedule para escrever `allDay`; rejeitar com mensagem clara mantém o modelo estável. Trigger para B: pedido real de "arrastar para dia inteiro".
- Guard: `'oldEvent' in info && info.event.allDay !== info.oldEvent.allDay` → `info.revert()` + toast. `EventResizeDoneInfo` não tem `oldEvent` e resize não troca allDay.
**D5 — Controle date-only.**
- Opções: A) prop `timeVisible?: boolean` no `ActivityDateTimeField` (default true) — popover renderiza só o Calendar, trigger formata só a data | B) `<Input type="date">` no sheet | C) componente novo `ActivityDateField`.
- Recomendação: A no sheet (mesmo controle C97 que a mesa já conhece; C97 declarou "a data-only já é meio caminho"); **B no form completo** (o form já usa inputs nativos `datetime-local`; `<Input type="date">` preserva o padrão do form e é ótimo no mobile — C103).
- Rejeitadas: A no form (mistura padrões no mesmo form); C (twin do componente existente).
**D6 — Estado do form completo (toggle).**
- Opções: A) `startAt`/`endAt` controlados via `useState` (inicializados de activity/prefill) | B) uncontrolled + `key` para remontar o input ao trocar modo.
- Recomendação: A — trocar o modo preserva a data digitada (deriva o date part do estado atual); B perde o valor digitado ao remontar (o toggle vem antes dos campos no fluxo natural: primeiro alterna, depois preenche — mas se preencheu antes, perde). Custo ~10 linhas.
**D7 — Prefill "Mais detalhes".**
- Opções: A) `allDay=1` + `startAt`/`endAt` como datas civis (`2026-08-10`) nos params | B) instantes ISO + `allDay=1`.
- Recomendação: A — data civil é o vocabulário do modo; `parseActivityCreatePrefill` ramifica em `allDay=1` (regex `YYYY-MM-DD` → instantes via lib); o `ActivityCreatePrefill` ganha `allDay?: boolean` e o form nasce com o toggle ligado. Rejeitada: B porque obriga o parse a reconstruir a data a partir do fuso.
**D8 — Surfaces de exibição (auditoria).** Todo lugar que formata `startAt`/`endAt` com hora precisa da branch allDay (só data / range):
- `formatActivityWhenLabel` (card + home search) → branch allDay;
- `ActivityOverviewTab` Início/Término → branch allDay;
- `MunicipalityDossier` (linha 89) → branch allDay;
- `searchHomeActivities` select ganha `allDay`/`endAt` e repassa;
- página da organização já imprime `dateStyle: 'short'` (só data) — sem mudança;
- `calendarFeed.ts` → `DTSTART;VALUE=DATE` / `DTEND;VALUE=DATE` com fim exclusivo.
### Componentes / mudanças
- **Migration:** `add_activity_all_day` — coluna `all_day boolean NOT NULL DEFAULT false` (Payload checkbox + `hasDefaultValue`? sem default explícito o schema exige o campo; default `false` no config, migration com `default: false`). Nunca editar migração já commitada; seguir o skill `payload-migrations`.
- **`src/lib/activityAllDay.ts`** (novo, puro): conversões de D2.
- **`src/lib/schemas/activity.ts`**: `activityFieldsSchema` ganha `allDay: z.boolean().optional()`; `validateSchedule` ramifica (allDay: Início obrigatório — mensagem "Informe a data de início do compromisso."; término opcional, `end >= start` — "A data de término deve ser igual ou posterior à de início."; timed inalterado); `activityRescheduleSchema` ganha `allDay: z.boolean()` (endAt obrigatório e `start <= end` quando allDay).
- **`src/collections/Activity.ts`**: campo `allDay` (label "Todo o dia", default false); hook `validateActivitySchedule` com a mesma branch; `activityStaffFieldSnapshot` inclui `allDay` (liderança não pode flipar).
- **`src/utilities/activityFormData.ts`**: `parseDateTimeFormField` ramifica no checkbox `allDay` — datas civis → instantes via lib/activityAllDay.
- **`src/components/campaign/activity/ActivityDateTimeField.tsx`**: prop `timeVisible` (default true) — trigger com `formatBahiaCivilDateLabel` (novo helper puro em `campaignTime.ts`, string arithmetic 24h-safe) e popover sem o bloco de selects.
- **`src/components/campaign/activity/ActivityInlineCreate.tsx`**: toggle "Todo o dia" (Checkbox + Field horizontal, precedente "Deputado presente") entre título e Início/Término; validação ramificada; submit com `allDay` + instantes da lib; "Mais detalhes" com `allDay=1` + datas civis; DrawerDescription com data quando on.
- **`src/components/campaign/activity/ActivityForm.tsx`**: toggle no topo do card "Data e horário"; start/end controlados (D6); `<Input type="date">` quando on (defaults de `formatIsoAsBahiaDateInput` = date part).
- **`src/components/campaign/activity/ActivityAgenda.tsx`**: `toEventInput` branch allDay (start date-only, end exclusivo, `allDay: true`); `allDaySlot` ligado (remover `allDaySlot={false}`); `persistSchedule` converte `startStr`/`endStr` exclusivos → storage e envia `allDay`; guard D4 no `handleScheduleChange`.
- **`src/utilities/activityUi.ts`**: `ActivityCreatePrefill.allDay`; `parseActivityCreatePrefill` ramifica; `buildActivityCreateHref` emite `allDay=1` + datas.
- **`src/utilities/activityViewModels.ts`**: `allDay` em `ActivityAgendaEvent`, `ActivityListViewModel`, `ActivityFormViewModel`, `ActivityDetailViewModel` + selects (`activityAgendaSelect`, `activityListSelect`, `activityFormSelect`, `activityDetailContextSelect`); `formatActivityWhenLabel`/`formatActivityHomeSearchSecondary` com branch.
- **`src/utilities/calendarFeed.ts`**: allDay → `DTSTART;VALUE=DATE:<início>` / `DTEND;VALUE=DATE:<fim exclusivo>`.
- **`src/components/campaign/municipality/MunicipalityDossier.tsx`** + **`src/utilities/homeSearch/searchHomeActivities.ts`**: branch allDay nos rótulos.
- **Access / Consent:** nenhum toque (staff-only, já gateado por `canCreateActivity`/`canReadActivity`).
### Dados → forma
N/A — a intenção declara `Dados: N/A`; a "forma" aqui é a convenção de storage (D1), decidida acima com rejeitadas.
## Fases verificáveis
1. **Schema + server** — migration `add_activity_all_day` + `pnpm migrate` local + `generate:types`; `lib/activityAllDay.ts` + unit tests; schema (`activity.ts`) + hooks da collection; `activityFormData.ts`; VMs + selects; iCal; tests unit/int de domínio.
2. **UI** — `ActivityDateTimeField.timeVisible`; sheet (toggle + validação + href); agenda (`toEventInput`, `allDaySlot`, `persistSchedule`, guard); form completo; surfaces de rótulo; tests de componente/unit.
3. **Gates** — `pnpm gate:fast` na iteração; `pnpm push` no fechamento (PR `--base main` + `Closes #505`).
## Rabbit holes / Não escopo (engenharia)
- Drop/conversão arrastando entre lane all-day e grade de horário → **guard D4 rejeita**; virar feature se pedido real.
- Clique na lane all-day abre o sheet com toggle ligado → defer (gate humano; a intenção fixa toggle nasce desligado).
- Semântica "Próximos" para todo-dia (evento começou hoje à 00:00 sai do filtro às 00:01) → comportamento pré-existente de eventos timed, fora do escopo.
- Identidade visual nova (cor/ícone) para todo-dia → anti-goal da intenção.
- Recorrência / fuso → anti-goals da intenção.
## Riscos e mitigação
- **Off-by-one no fim do intervalo** (FullCalendar/iCal exigem fim exclusivo): conversões só em `lib/activityAllDay.ts` com unit tests por extensão (dia único, multi-dia, virada de mês/ano) — e o guard D4 impede o estado inconsistente por drag.
- **Dados legados**: eventos existentes têm `all_day = false` (default) → zero ambiguidade; timed com horário 00:00 raro continua timed (flag é o estado, não a hora).
- **DST**: America/Bahia é fixa −03 (sem DST desde 2019) → meia-noite baiana é determinística; nada a tratar.
- **Fim igual início (dia único)**: validação allDay aceita `endAt === startAt`; as queries de overlap da agenda são por instante e funcionam com igualdade (evento do dia aparece na janela do dia).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (toggle no sheet; só datas multi-dia; faixa no calendário; preservação na edição; horário intacto)
- [ ] Invariantes AGENTS/engineering-standards (transações inalteradas — create/update já transacional; `overrideAccess: false` mantido; copy pt-BR / identificadores en)
- [ ] Testes de domínio: unit para `lib/activityAllDay`, schemas (create/update/reschedule allDay), prefill/href, componente inline (toggle+submit+validação), VMs, iCal; int para create/reschedule allDay round-trip e hook da collection

View file

@ -6,6 +6,7 @@ import { CampaignListPagination } from '@/components/campaign/shared/CampaignLis
import { Badge } from '@/components/ui/Badge' import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { formatAllDayRangeLabel } from '@/lib/activityAllDay'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { campaignDemandKindLabels, campaignDemandStatusLabels } from '@/lib/schemas/campaignDemand' import { campaignDemandKindLabels, campaignDemandStatusLabels } from '@/lib/schemas/campaignDemand'
import type { getActivityDetailPageData } from '@/utilities/activityDetailPageData' import type { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
@ -65,11 +66,23 @@ export const ActivityOverviewTab = ({
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Início</dt> <dt className="text-muted-foreground">Início</dt>
<dd>{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'A definir'}</dd> <dd>
{view.startAt
? view.allDay
? formatAllDayRangeLabel(view.startAt)
: formatBahiaDateTimeLabel(view.startAt)
: 'A definir'}
</dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Término</dt> <dt className="text-muted-foreground">Término</dt>
<dd>{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}</dd> <dd>
{view.endAt
? view.allDay
? formatAllDayRangeLabel(view.endAt)
: formatBahiaDateTimeLabel(view.endAt)
: '—'}
</dd>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<dt className="text-muted-foreground">Tags</dt> <dt className="text-muted-foreground">Tags</dt>

View file

@ -5,6 +5,7 @@ import type {
} from 'payload' } from 'payload'
import { APIError } from 'payload' import { APIError } from 'payload'
import { allDayRangeValid } from '@/lib/activityAllDay'
import { relationshipId } from '@/lib/relationship' import { relationshipId } from '@/lib/relationship'
import { import {
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE, ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
@ -46,6 +47,7 @@ const activityStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
tags: Array.isArray(doc.tags) ? [...doc.tags].sort() : [], tags: Array.isArray(doc.tags) ? [...doc.tags].sort() : [],
status: doc.status ?? null, status: doc.status ?? null,
description: trimmedText(doc.description), description: trimmedText(doc.description),
allDay: Boolean(doc.allDay),
startAt: doc.startAt ?? null, startAt: doc.startAt ?? null,
endAt: doc.endAt ?? null, endAt: doc.endAt ?? null,
municipality: relationshipId(doc.municipality), municipality: relationshipId(doc.municipality),
@ -96,16 +98,29 @@ const validateActivitySchedule: CollectionBeforeValidateHook = ({
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
const startAt = nextData.startAt ?? null const startAt = nextData.startAt ?? null
const endAt = nextData.endAt ?? null const endAt = nextData.endAt ?? null
const allDay = Boolean(nextData.allDay)
// C14: startAt is always required (no more rascunho without date). // C14: startAt is always required (no more rascunho without date).
if (!startAt) { if (!startAt) {
throw new APIError('Informe a data e horário de início do compromisso.', 400) throw new APIError(
allDay
? 'Informe a data de início do compromisso.'
: 'Informe a data e horário de início do compromisso.',
400,
)
} }
if (startAt && endAt) { if (startAt && endAt) {
const start = new Date(startAt as string) const start = new Date(startAt as string)
const end = new Date(endAt as string) const end = new Date(endAt as string)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || !(start < end)) { const valid = !Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())
if (allDay) {
// C104 — all-day is day granularity: end date may equal the start date
// (single day), never precede it.
if (valid && !allDayRangeValid(startAt as string, endAt as string)) {
throw new APIError('A data de término deve ser igual ou posterior à de início.', 400)
}
} else if (!valid || !(start < end)) {
throw new APIError('O horário de término deve ser posterior ao de início.', 400) throw new APIError('O horário de término deve ser posterior ao de início.', 400)
} }
} }
@ -455,6 +470,21 @@ export const Activity: CollectionConfig = {
description: 'Marque quando o deputado Jorge Solla estiver presente na atividade.', description: 'Marque quando o deputado Jorge Solla estiver presente na atividade.',
}, },
}, },
{
// C104 — all-day commitments (one or several whole days) keep the date
// granularity the toggle promised: when true, startAt is the first day
// at 00:00 America/Bahia and endAt the last day at 00:00 (inclusive end;
// single-day events have startAt === endAt). Conversions to the
// exclusive end FullCalendar/iCal expect live in lib/activityAllDay.ts.
name: 'allDay',
type: 'checkbox',
label: 'Todo o dia',
defaultValue: false,
index: true,
admin: {
description: 'Compromisso que ocupa um ou mais dias inteiros, sem horário.',
},
},
{ {
name: 'startAt', name: 'startAt',
type: 'date', type: 'date',

View file

@ -35,6 +35,11 @@ import type { RelationOption } from '@/components/campaign/shared/RelationMultiS
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useIsMobileMeasured } from '@/hooks/use-mobile' import { useIsMobileMeasured } from '@/hooks/use-mobile'
import { formatBahiaCivilDate } from '@/lib/campaignTime' import { formatBahiaCivilDate } from '@/lib/campaignTime'
import {
allDayEndInstantFromExclusive,
allDayExclusiveEndDate,
allDayStartInstant,
} from '@/lib/activityAllDay'
import { import {
ACTIVITY_RESCHEDULE_FAILED_MESSAGE, ACTIVITY_RESCHEDULE_FAILED_MESSAGE,
activityStatusLabels, activityStatusLabels,
@ -83,9 +88,13 @@ const AGENDA_DAY_SCROLL_TIME = '08:00:00'
const toEventInput = (event: ActivityAgendaEvent): EventInput => ({ const toEventInput = (event: ActivityAgendaEvent): EventInput => ({
id: String(event.id), id: String(event.id),
title: event.title, title: event.title,
start: event.startAt, // C104 — all-day events use the date parts: start as-is (first day), end as
end: event.endAt ?? undefined, // the exclusive day after the last stored day (FullCalendar's convention).
allDay: false, start: event.allDay ? event.startAt.slice(0, 10) : event.startAt,
end: event.allDay
? allDayExclusiveEndDate(event.endAt ?? event.startAt)
: (event.endAt ?? undefined),
allDay: event.allDay,
url: event.href, url: event.href,
startEditable: event.canReschedule, startEditable: event.canReschedule,
durationEditable: event.canReschedule, durationEditable: event.canReschedule,
@ -359,11 +368,13 @@ export const ActivityAgenda = ({
const persistSchedule = useCallback( const persistSchedule = useCallback(
async ({ async ({
id, id,
allDay,
startAt, startAt,
endAt, endAt,
revert, revert,
}: { }: {
id: string id: string
allDay: boolean
startAt: string startAt: string
endAt: string | null endAt: string | null
revert: () => void revert: () => void
@ -376,7 +387,7 @@ export const ActivityAgenda = ({
pendingEventIDs.current.add(id) pendingEventIDs.current.add(id)
setSavingCount((count) => count + 1) setSavingCount((count) => count + 1)
try { try {
const result = await rescheduleActivity({ id: Number(id), startAt, endAt }) const result = await rescheduleActivity({ id: Number(id), allDay, startAt, endAt })
if (!mountedRef.current) return if (!mountedRef.current) return
if (!result.ok) { if (!result.ok) {
@ -405,12 +416,24 @@ export const ActivityAgenda = ({
const handleScheduleChange = useCallback( const handleScheduleChange = useCallback(
(info: EventDropInfo | EventResizeDoneInfo) => { (info: EventDropInfo | EventResizeDoneInfo) => {
void persistSchedule({ // C104 — the all-day lane exists now; dragging an event between the lane
id: info.event.id, // and the time grid would flip its allDay flag. Converting a commitment
startAt: info.event.startStr, // by dragging is out of scope, so such drops are rejected.
endAt: info.event.endStr || null, if ('oldEvent' in info && info.event.allDay !== info.oldEvent.allDay) {
revert: info.revert, info.revert()
}) toast.error(
'Não é possível transformar um compromisso em dia inteiro (ou vice-versa) arrastando-o. Edite pelo formulário.',
)
return
}
const allDay = info.event.allDay
const startAt = allDay ? allDayStartInstant(info.event.startStr) : info.event.startStr
const endAt = allDay
? allDayEndInstantFromExclusive(
info.event.endStr || allDayExclusiveEndDate(allDayStartInstant(info.event.startStr)),
)
: info.event.endStr || null
void persistSchedule({ id: info.event.id, allDay, startAt, endAt, revert: info.revert })
}, },
[persistSchedule], [persistSchedule],
) )
@ -459,7 +482,8 @@ export const ActivityAgenda = ({
slotMaxTime="22:00:00" slotMaxTime="22:00:00"
scrollTime={AGENDA_DAY_SCROLL_TIME} scrollTime={AGENDA_DAY_SCROLL_TIME}
nowIndicator nowIndicator
allDaySlot={false} // C104 — the all-day lane carries full-day commitments at the top of
// the day/week views (Google Calendar behavior the mesa expects).
dayMaxEvents dayMaxEvents
editable={!isLoading && savingCount === 0} editable={!isLoading && savingCount === 0}
eventLongPressDelay={650} eventLongPressDelay={650}

View file

@ -30,7 +30,12 @@ export const ActivityCard = ({ activity }: { activity: ActivityListViewModel })
<CardContent className="flex flex-col gap-2"> <CardContent className="flex flex-col gap-2">
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<CalendarIcon aria-hidden="true" className="size-4 shrink-0" /> <CalendarIcon aria-hidden="true" className="size-4 shrink-0" />
<span>{formatActivityWhenLabel(activity.startAt)}</span> <span>
{formatActivityWhenLabel(activity.startAt, {
allDay: activity.allDay,
endAt: activity.endAt,
})}
</span>
</div> </div>
{activity.locationLabel ? ( {activity.locationLabel ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground"> <div className="flex items-center gap-1.5 text-sm text-muted-foreground">

View file

@ -10,6 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover
import { import {
floorToMinuteStep, floorToMinuteStep,
formatBahiaCivilDate, formatBahiaCivilDate,
formatBahiaCivilDateLabel,
formatBahiaCivilDateTimeLabel, formatBahiaCivilDateTimeLabel,
hourOptions, hourOptions,
minuteOptionsForStep, minuteOptionsForStep,
@ -25,6 +26,11 @@ import { cn } from '@/lib/utils'
* 00–23 / 00–45, so no browser locale or Intl hour cycle can inject AM/PM. * 00–23 / 00–45, so no browser locale or Intl hour cycle can inject AM/PM.
* The parent renders the surrounding Field/FieldError; this component is the * The parent renders the surrounding Field/FieldError; this component is the
* control itself (trigger + popover with calendar and step selects). * control itself (trigger + popover with calendar and step selects).
*
* C104 — `timeVisible={false}` is the all-day mode: the trigger formats only
* the date and the popover renders just the calendar (no time selects); the
* value contract stays the same civil string, so the date part is the
* all-day day and the time part is ignored on submit.
*/ */
export const ActivityDateTimeField = ({ export const ActivityDateTimeField = ({
id, id,
@ -32,6 +38,7 @@ export const ActivityDateTimeField = ({
onValueChange, onValueChange,
invalid = false, invalid = false,
errorId, errorId,
timeVisible = true,
}: { }: {
id: string id: string
value: string value: string
@ -39,6 +46,8 @@ export const ActivityDateTimeField = ({
invalid?: boolean invalid?: boolean
/** FieldError id to announce on focus — parity with the input it replaces. */ /** FieldError id to announce on focus — parity with the input it replaces. */
errorId?: string errorId?: string
/** When false, hide the time selects and label the trigger with the date only. */
timeVisible?: boolean
}) => { }) => {
const civil = floorToMinuteStep(value) const civil = floorToMinuteStep(value)
const datePart = civil.slice(0, 10) const datePart = civil.slice(0, 10)
@ -71,7 +80,9 @@ export const ActivityDateTimeField = ({
'border-destructive ring-3 ring-destructive/20 focus-visible:border-destructive focus-visible:ring-destructive/20', 'border-destructive ring-3 ring-destructive/20 focus-visible:border-destructive focus-visible:ring-destructive/20',
)} )}
> >
<span className="truncate">{formatBahiaCivilDateTimeLabel(civil)}</span> <span className="truncate">
{timeVisible ? formatBahiaCivilDateTimeLabel(civil) : formatBahiaCivilDateLabel(civil)}
</span>
<CalendarIcon <CalendarIcon
data-icon="inline-end" data-icon="inline-end"
aria-hidden="true" aria-hidden="true"
@ -93,38 +104,42 @@ export const ActivityDateTimeField = ({
className="w-full p-2" className="w-full p-2"
/> />
) : null} ) : null}
<div className="flex flex-col gap-3 border-t p-3"> {timeVisible ? (
<div className="grid grid-cols-2 gap-3"> <div className="flex flex-col gap-3 border-t p-3">
<div className="flex flex-col gap-1"> <div className="grid grid-cols-2 gap-3">
<span className="text-xs font-medium text-muted-foreground select-none">Hora</span> <div className="flex flex-col gap-1">
<NativeSelect <span className="text-xs font-medium text-muted-foreground select-none">Hora</span>
aria-label="Hora" <NativeSelect
value={hour} aria-label="Hora"
onChange={(event) => setHour(event.target.value)} value={hour}
> onChange={(event) => setHour(event.target.value)}
{hourOptions.map((option) => ( >
<NativeSelectOption key={option} value={option}> {hourOptions.map((option) => (
{option}h <NativeSelectOption key={option} value={option}>
</NativeSelectOption> {option}h
))} </NativeSelectOption>
</NativeSelect> ))}
</div> </NativeSelect>
<div className="flex flex-col gap-1"> </div>
<span className="text-xs font-medium text-muted-foreground select-none">Minuto</span> <div className="flex flex-col gap-1">
<NativeSelect <span className="text-xs font-medium text-muted-foreground select-none">
aria-label="Minuto" Minuto
value={minute} </span>
onChange={(event) => setMinute(event.target.value)} <NativeSelect
> aria-label="Minuto"
{minuteOptionsForStep().map((option) => ( value={minute}
<NativeSelectOption key={option} value={option}> onChange={(event) => setMinute(event.target.value)}
{option} >
</NativeSelectOption> {minuteOptionsForStep().map((option) => (
))} <NativeSelectOption key={option} value={option}>
</NativeSelect> {option}
</NativeSelectOption>
))}
</NativeSelect>
</div>
</div> </div>
</div> </div>
</div> ) : null}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )

View file

@ -1,7 +1,7 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import { useActionState } from 'react' import { useActionState, useState } from 'react'
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields' import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput' import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
@ -70,6 +70,20 @@ const ActivityFormFields = ({
const initialStartAt = activity?.startAt ?? initialValues?.startAt const initialStartAt = activity?.startAt ?? initialValues?.startAt
const initialEndAt = activity?.endAt ?? initialValues?.endAt const initialEndAt = activity?.endAt ?? initialValues?.endAt
const initialMunicipalityId = activity?.municipalityId ?? initialValues?.municipalityId const initialMunicipalityId = activity?.municipalityId ?? initialValues?.municipalityId
const [allDay, setAllDay] = useState(activity?.allDay ?? initialValues?.allDay ?? false)
// C104 — controlled so switching the toggle keeps the date the staff typed:
// the date input reads/preserves the date part, the datetime input the
// whole civil value; on submit the parser branches on the allDay checkbox.
const [startAt, setStartAt] = useState(
initialStartAt ? formatIsoAsBahiaDateTimeInput(initialStartAt) : '',
)
const [endAt, setEndAt] = useState(
initialEndAt ? formatIsoAsBahiaDateTimeInput(initialEndAt) : '',
)
const startDateOnly = startAt.slice(0, 10)
const endDateOnly = endAt.slice(0, 10)
const setStartDate = (date: string) => setStartAt(`${date}T${startAt.slice(11) || '00:00'}`)
const setEndDate = (date: string) => setEndAt(`${date}T${endAt.slice(11) || '00:00'}`)
return ( return (
<FieldGroup> <FieldGroup>
@ -139,38 +153,79 @@ const ActivityFormFields = ({
<CardTitle>Data e horário</CardTitle> <CardTitle>Data e horário</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 sm:grid-cols-2"> <div className="flex flex-col gap-4">
<Field data-invalid={Boolean(errorFor('startAt'))}> <FieldLabel>
<FieldLabel htmlFor="startAt">Início *</FieldLabel> <Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
<Input <Checkbox
id="startAt" name="allDay"
name="startAt" checked={allDay}
type="datetime-local" onCheckedChange={(next) => setAllDay(Boolean(next))}
defaultValue={initialStartAt ? formatIsoAsBahiaDateTimeInput(initialStartAt) : ''} />
required <span>Todo o dia</span>
className="min-h-11" </Field>
aria-invalid={Boolean(errorFor('startAt'))} </FieldLabel>
aria-describedby={errorFor('startAt') ? 'startAt-error' : undefined} <div className="grid gap-4 sm:grid-cols-2">
/> <Field data-invalid={Boolean(errorFor('startAt'))}>
{errorFor('startAt') ? ( <FieldLabel htmlFor="startAt">Início *</FieldLabel>
<FieldError id="startAt-error">{errorFor('startAt')}</FieldError> {allDay ? (
) : null} <Input
</Field> id="startAt"
<Field data-invalid={Boolean(errorFor('endAt'))}> name="startAt"
<FieldLabel htmlFor="endAt">Término</FieldLabel> type="date"
<Input value={startDateOnly}
id="endAt" onChange={(event) => setStartDate(event.target.value)}
name="endAt" required
type="datetime-local" className="min-h-11"
defaultValue={initialEndAt ? formatIsoAsBahiaDateTimeInput(initialEndAt) : ''} aria-invalid={Boolean(errorFor('startAt'))}
className="min-h-11" aria-describedby={errorFor('startAt') ? 'startAt-error' : undefined}
aria-invalid={Boolean(errorFor('endAt'))} />
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined} ) : (
/> <Input
{errorFor('endAt') ? ( id="startAt"
<FieldError id="endAt-error">{errorFor('endAt')}</FieldError> name="startAt"
) : null} type="datetime-local"
</Field> value={startAt}
onChange={(event) => setStartAt(event.target.value)}
required
className="min-h-11"
aria-invalid={Boolean(errorFor('startAt'))}
aria-describedby={errorFor('startAt') ? 'startAt-error' : undefined}
/>
)}
{errorFor('startAt') ? (
<FieldError id="startAt-error">{errorFor('startAt')}</FieldError>
) : null}
</Field>
<Field data-invalid={Boolean(errorFor('endAt'))}>
<FieldLabel htmlFor="endAt">Término</FieldLabel>
{allDay ? (
<Input
id="endAt"
name="endAt"
type="date"
value={endDateOnly}
onChange={(event) => setEndDate(event.target.value)}
className="min-h-11"
aria-invalid={Boolean(errorFor('endAt'))}
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined}
/>
) : (
<Input
id="endAt"
name="endAt"
type="datetime-local"
value={endAt}
onChange={(event) => setEndAt(event.target.value)}
className="min-h-11"
aria-invalid={Boolean(errorFor('endAt'))}
aria-describedby={errorFor('endAt') ? 'endAt-error' : undefined}
/>
)}
{errorFor('endAt') ? (
<FieldError id="endAt-error">{errorFor('endAt')}</FieldError>
) : null}
</Field>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -13,6 +13,7 @@ import { ResponsibleMultiSelect } from '@/components/campaign/shared/Responsible
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox' import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
import { Alert, AlertDescription } from '@/components/ui/Alert' import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
@ -24,6 +25,7 @@ import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner' import { Spinner } from '@/components/ui/Spinner'
import { allDayEndInstant, allDayRangeValid, allDayStartInstant } from '@/lib/activityAllDay'
import { import {
formatBahiaDateTimeLabel, formatBahiaDateTimeLabel,
formatIsoAsBahiaDateTimeInput, formatIsoAsBahiaDateTimeInput,
@ -112,6 +114,7 @@ const ActivityInlineCreateForm = ({
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [start, setStart] = useState(formatIsoAsBahiaDateTimeInput(startAt)) const [start, setStart] = useState(formatIsoAsBahiaDateTimeInput(startAt))
const [end, setEnd] = useState(formatIsoAsBahiaDateTimeInput(endAt)) const [end, setEnd] = useState(formatIsoAsBahiaDateTimeInput(endAt))
const [allDay, setAllDay] = useState(false)
const [locality, setLocality] = useState('') const [locality, setLocality] = useState('')
const [tags, setTags] = useState<string[]>([]) const [tags, setTags] = useState<string[]>([])
const preselectedMunicipalityId = const preselectedMunicipalityId =
@ -126,12 +129,19 @@ const ActivityInlineCreateForm = ({
const errorFor = (name: string) => fieldErrors[name]?.[0] const errorFor = (name: string) => fieldErrors[name]?.[0]
const allDayStartDate = start.slice(0, 10)
const allDayEndDate = end ? end.slice(0, 10) : allDayStartDate
// C105 — the href mirrors the LIVE state (times edited in the sheet, typed // C105 — the href mirrors the LIVE state (times edited in the sheet, typed
// tags), not the slot draft, so "Mais detalhes" carries everything the user // tags), not the slot draft, so "Mais detalhes" carries everything the user
// actually filled. // actually filled.
const moreDetailsHref = buildActivityCreateHref(agendaState, { const moreDetailsHref = buildActivityCreateHref(agendaState, {
startAt: parseBahiaDateTimeInput(start) ?? undefined, ...(allDay
endAt: end ? (parseBahiaDateTimeInput(end) ?? undefined) : undefined, ? { allDay: true, startAt: allDayStartDate, endAt: allDayEndDate }
: {
startAt: parseBahiaDateTimeInput(start) ?? undefined,
endAt: end ? (parseBahiaDateTimeInput(end) ?? undefined) : undefined,
}),
municipalityId: municipalityValue ? Number(municipalityValue) : undefined, municipalityId: municipalityValue ? Number(municipalityValue) : undefined,
title: title.trim() || undefined, title: title.trim() || undefined,
tags: tags.length > 0 ? tags : undefined, tags: tags.length > 0 ? tags : undefined,
@ -152,9 +162,17 @@ const ActivityInlineCreateForm = ({
nextFieldErrors.title = ['Informe um título com ao menos 2 caracteres.'] nextFieldErrors.title = ['Informe um título com ao menos 2 caracteres.']
} }
if (!municipalityId) nextFieldErrors.municipality = ['Informe o município.'] if (!municipalityId) nextFieldErrors.municipality = ['Informe o município.']
if (!startIso) nextFieldErrors.startAt = ['Informe a data e horário de início do compromisso.'] if (allDay) {
if (startIso && endIso && new Date(endIso) <= new Date(startIso)) { if (!startIso) nextFieldErrors.startAt = ['Informe a data de início do compromisso.']
nextFieldErrors.endAt = ['O horário de término deve ser posterior ao de início.'] if (startIso && endIso && !allDayRangeValid(startIso, endIso)) {
nextFieldErrors.endAt = ['A data de término deve ser igual ou posterior à de início.']
}
} else {
if (!startIso)
nextFieldErrors.startAt = ['Informe a data e horário de início do compromisso.']
if (startIso && endIso && new Date(endIso) <= new Date(startIso)) {
nextFieldErrors.endAt = ['O horário de término deve ser posterior ao de início.']
}
} }
if (Object.keys(nextFieldErrors).length > 0) { if (Object.keys(nextFieldErrors).length > 0) {
setFieldErrors(nextFieldErrors) setFieldErrors(nextFieldErrors)
@ -169,8 +187,16 @@ const ActivityInlineCreateForm = ({
const result = await createActivityInline({ const result = await createActivityInline({
title: trimmedTitle, title: trimmedTitle,
municipality: municipalityId, municipality: municipalityId,
startAt: startIso as string, ...(allDay
...(endIso ? { endAt: endIso } : {}), ? {
allDay: true,
startAt: allDayStartInstant(allDayStartDate),
...(end ? { endAt: allDayEndInstant(allDayEndDate) } : {}),
}
: {
startAt: startIso as string,
...(endIso ? { endAt: endIso } : {}),
}),
...(locality.trim() ? { locality: locality.trim() } : {}), ...(locality.trim() ? { locality: locality.trim() } : {}),
...(responsible ? { responsible } : {}), ...(responsible ? { responsible } : {}),
// C105 — the tags mirror state (every chip mutation fires `onChange`), // C105 — the tags mirror state (every chip mutation fires `onChange`),
@ -214,6 +240,17 @@ const ActivityInlineCreateForm = ({
) : null} ) : null}
</Field> </Field>
<Field>
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
<Checkbox
checked={allDay}
onCheckedChange={(next) => setAllDay(Boolean(next))}
aria-label="Todo o dia"
/>
<span>Todo o dia</span>
</Field>
</Field>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<Field data-invalid={Boolean(errorFor('startAt'))}> <Field data-invalid={Boolean(errorFor('startAt'))}>
<FieldLabel htmlFor="inline-startAt">Início *</FieldLabel> <FieldLabel htmlFor="inline-startAt">Início *</FieldLabel>
@ -223,6 +260,7 @@ const ActivityInlineCreateForm = ({
onValueChange={setStart} onValueChange={setStart}
invalid={Boolean(errorFor('startAt'))} invalid={Boolean(errorFor('startAt'))}
errorId={errorFor('startAt') ? 'inline-startAt-error' : undefined} errorId={errorFor('startAt') ? 'inline-startAt-error' : undefined}
timeVisible={!allDay}
/> />
{errorFor('startAt') ? ( {errorFor('startAt') ? (
<FieldError id="inline-startAt-error">{errorFor('startAt')}</FieldError> <FieldError id="inline-startAt-error">{errorFor('startAt')}</FieldError>
@ -236,6 +274,7 @@ const ActivityInlineCreateForm = ({
onValueChange={setEnd} onValueChange={setEnd}
invalid={Boolean(errorFor('endAt'))} invalid={Boolean(errorFor('endAt'))}
errorId={errorFor('endAt') ? 'inline-endAt-error' : undefined} errorId={errorFor('endAt') ? 'inline-endAt-error' : undefined}
timeVisible={!allDay}
/> />
{errorFor('endAt') ? ( {errorFor('endAt') ? (
<FieldError id="inline-endAt-error">{errorFor('endAt')}</FieldError> <FieldError id="inline-endAt-error">{errorFor('endAt')}</FieldError>

View file

@ -6,6 +6,7 @@ import { MunicipalityBaselineCard } from '@/components/campaign/municipality/Mun
import { MunicipalityStrategyCard } from '@/components/campaign/municipality/MunicipalityStrategyCard' import { MunicipalityStrategyCard } from '@/components/campaign/municipality/MunicipalityStrategyCard'
import { MunicipalityUpdateFeed } from '@/components/campaign/municipality/MunicipalityUpdateFeed' import { MunicipalityUpdateFeed } from '@/components/campaign/municipality/MunicipalityUpdateFeed'
import { Badge } from '@/components/ui/Badge' import { Badge } from '@/components/ui/Badge'
import { formatAllDayRangeLabel } from '@/lib/activityAllDay'
import type { MunicipalityAgeBandKey } from '@/lib/bahiaMunicipalityDemographics' import type { MunicipalityAgeBandKey } from '@/lib/bahiaMunicipalityDemographics'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { formatElectionNumber } from '@/lib/electionFormat' import { formatElectionNumber } from '@/lib/electionFormat'
@ -86,7 +87,11 @@ const ActivityListItem = ({ activity }: { activity: ActivityListViewModel }) =>
{activity.deputyPresent ? <Badge>Deputado presente</Badge> : null} {activity.deputyPresent ? <Badge>Deputado presente</Badge> : null}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{activity.startAt ? formatBahiaDateTimeLabel(activity.startAt) : 'Sem data definida'} {activity.startAt
? activity.allDay
? formatAllDayRangeLabel(activity.startAt, activity.endAt)
: formatBahiaDateTimeLabel(activity.startAt)
: 'Sem data definida'}
{activity.locality ? ` · ${activity.locality}` : ''} {activity.locality ? ` · ${activity.locality}` : ''}
</p> </p>
</li> </li>

66
src/lib/activityAllDay.ts Normal file
View file

@ -0,0 +1,66 @@
import {
civilDateDaysBetween,
formatBahiaCivilDate,
parseBahiaDateTimeInput,
subtractBahiaCivilDays,
} from '@/lib/campaignTime'
/**
* C104 — all-day activity schedule conventions. When `activity.allDay` is
* true, the stored instants are day-boundary in America/Bahia (fixed −03, no
* DST): `startAt` = first day at 00:00, `endAt` = last day at 00:00 — an
* INCLUSIVE end (the date part of `endAt` IS the last day; a single-day
* commitment has `startAt === endAt`). The consumer's intent is stored as-is;
* only the calendar boundaries that need an exclusive end (FullCalendar
* events, iCal `DTEND;VALUE=DATE`) convert through this module.
*
* This module is pure and client-safe: the agenda (client) and the server
* (schema, form parsing, iCal feed) share the same conversions, so the
* inclusive/exclusive off-by-one can only live here — where tests pin it.
*/
const civilDatePattern = /^\d{4}-\d{2}-\d{2}$/
export const isCivilDate = (value: string): boolean => civilDatePattern.test(value)
/** The date part (`aaaa-mm-dd`, Bahia civil) of a stored all-day instant. */
export const allDayCivilDateOf = (iso: string): string => formatBahiaCivilDate(new Date(iso))
/** Stored start instant for the first day of an all-day commitment. */
export const allDayStartInstant = (civilDate: string): string => {
const instant = parseBahiaDateTimeInput(`${civilDate}T00:00`)
if (!instant) throw new Error('Data de dia inteiro inválida.')
return instant
}
/** Stored end instant for the last day of an all-day commitment (inclusive). */
export const allDayEndInstant = (civilDate: string): string => allDayStartInstant(civilDate)
/** Calendar-exclusive end: the day AFTER the last stored day. */
export const allDayExclusiveEndDate = (endIso: string): string =>
subtractBahiaCivilDays(allDayCivilDateOf(endIso), -1)
/** Exclusive end date (FullCalendar drop/resize) → stored inclusive instant. */
export const allDayEndInstantFromExclusive = (exclusiveDate: string): string =>
allDayEndInstant(subtractBahiaCivilDays(exclusiveDate, 1))
/** All-day ranges allow the end date to equal the start date. */
export const allDayRangeValid = (startIso: string, endIso: string): boolean =>
civilDateDaysBetween(allDayCivilDateOf(startIso), allDayCivilDateOf(endIso)) >= 0
const formatCivilDateLabel = (iso: string): string => {
const [year, month, day] = allDayCivilDateOf(iso).split('-')
return `${day}/${month}/${year}`
}
/**
* pt-BR label for an all-day commitment: "10/08/2026" for a single day,
* "10/08/2026 a 12/08/2026" for a multi-day range. Pure string arithmetic —
* the label never depends on the browser locale (C97 precedent).
*/
export const formatAllDayRangeLabel = (startIso: string, endIso?: string | null): string => {
const start = formatCivilDateLabel(startIso)
if (!endIso) return start
const end = formatCivilDateLabel(endIso)
return end === start ? start : `${start} a ${end}`
}

View file

@ -191,3 +191,14 @@ export const formatBahiaCivilDateTimeLabel = (civil: string): string => {
const [, year, month, day, hour, minute] = match const [, year, month, day, hour, minute] = match
return `${day}/${month}/${year} às ${hour}:${minute}` return `${day}/${month}/${year} às ${hour}:${minute}`
} }
/**
* Date-only pt-BR label of a civil datetime input value (`dd/mm/aaaa`) —
* the C104 all-day mode of the date+time picker, same string arithmetic.
*/
export const formatBahiaCivilDateLabel = (civil: string): string => {
const match = bahiaDateTimeInputPattern.exec(civil)
if (!match) return civil.slice(0, 10)
const [, year, month, day] = match
return `${day}/${month}/${year}`
}

View file

@ -1,5 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
import { allDayRangeValid } from '@/lib/activityAllDay'
import { relationshipId } from '@/lib/relationship' import { relationshipId } from '@/lib/relationship'
import { campaignDemandCreateSchema } from '@/lib/schemas/campaignDemandInput' import { campaignDemandCreateSchema } from '@/lib/schemas/campaignDemandInput'
import { import {
@ -125,6 +126,7 @@ const activityFieldsSchema = z.object({
status: z.enum(activityStatuses).optional(), status: z.enum(activityStatuses).optional(),
description: trimmedOptionalText(4000), description: trimmedOptionalText(4000),
deputyPresent: z.boolean().optional(), deputyPresent: z.boolean().optional(),
allDay: z.boolean().optional(),
startAt: z.string().datetime().optional().nullable(), startAt: z.string().datetime().optional().nullable(),
endAt: z.string().datetime().optional().nullable(), endAt: z.string().datetime().optional().nullable(),
municipality: positiveRelationshipId, municipality: positiveRelationshipId,
@ -137,6 +139,7 @@ const activityFieldsSchema = z.object({
const validateSchedule = ( const validateSchedule = (
data: { data: {
status?: ActivityStatus | null status?: ActivityStatus | null
allDay?: boolean | null
startAt?: string | null startAt?: string | null
endAt?: string | null endAt?: string | null
}, },
@ -146,7 +149,9 @@ const validateSchedule = (
if (requireStart && !data.startAt) { if (requireStart && !data.startAt) {
context.addIssue({ context.addIssue({
code: 'custom', code: 'custom',
message: 'Informe a data e horário de início do compromisso.', message: data.allDay
? 'Informe a data de início do compromisso.'
: 'Informe a data e horário de início do compromisso.',
path: ['startAt'], path: ['startAt'],
}) })
} }
@ -154,7 +159,18 @@ const validateSchedule = (
if (data.startAt && data.endAt) { if (data.startAt && data.endAt) {
const start = new Date(data.startAt) const start = new Date(data.startAt)
const end = new Date(data.endAt) const end = new Date(data.endAt)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || !(start < end)) { const valid = !Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())
if (valid && data.allDay) {
// C104 — all-day ranges are day granularity: the end date may equal the
// start date (single-day commitment), never precede it.
if (!allDayRangeValid(data.startAt, data.endAt)) {
context.addIssue({
code: 'custom',
message: 'A data de término deve ser igual ou posterior à de início.',
path: ['endAt'],
})
}
} else if (!valid || !(start < end)) {
context.addIssue({ context.addIssue({
code: 'custom', code: 'custom',
message: 'O horário de término deve ser posterior ao de início.', message: 'O horário de término deve ser posterior ao de início.',
@ -197,10 +213,32 @@ export const activityAgendaRequestSchema = z
export const activityRescheduleSchema = z export const activityRescheduleSchema = z
.object({ .object({
id: positiveRelationshipId, id: positiveRelationshipId,
// C104 — the agenda knows the event's all-day state; the client converts
// FullCalendar's exclusive end into the stored inclusive convention and
// sends ready instants. allDay only relaxes the ordering rule here.
allDay: z.boolean(),
startAt: isoInstantSchema, startAt: isoInstantSchema,
endAt: isoInstantSchema.nullable(), endAt: isoInstantSchema.nullable(),
}) })
.superRefine((data, context) => { .superRefine((data, context) => {
if (data.allDay) {
if (!data.endAt) {
context.addIssue({
code: 'custom',
message: 'Informe a data de término do compromisso de dia inteiro.',
path: ['endAt'],
})
return
}
if (!allDayRangeValid(data.startAt, data.endAt)) {
context.addIssue({
code: 'custom',
message: 'A data de término deve ser igual ou posterior à de início.',
path: ['endAt'],
})
}
return
}
validateSchedule(data, context, true) validateSchedule(data, context, true)
}) })
.transform((data) => ({ .transform((data) => ({

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "activity" ADD COLUMN "all_day" boolean DEFAULT false;
CREATE INDEX "activity_all_day_idx" ON "activity" USING btree ("all_day");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP INDEX "activity_all_day_idx";
ALTER TABLE "activity" DROP COLUMN "all_day";`)
}

View file

@ -46,6 +46,7 @@ import * as migration_20260807_030000_unify_municipality_update from './20260807
import * as migration_20260808_134911_add_calendar_feed from './20260808_134911_add_calendar_feed'; import * as migration_20260808_134911_add_calendar_feed from './20260808_134911_add_calendar_feed';
import * as migration_20260808_184113_remodel_activity_responsible from './20260808_184113_remodel_activity_responsible'; import * as migration_20260808_184113_remodel_activity_responsible from './20260808_184113_remodel_activity_responsible';
import * as migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors from './20260809_204728_add_campaign_user_contact_and_leadership_advisors'; import * as migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors from './20260809_204728_add_campaign_user_contact_and_leadership_advisors';
import * as migration_20260810_010844_add_activity_all_day from './20260810_010844_add_activity_all_day';
export const migrations = [ export const migrations = [
{ {
@ -286,6 +287,11 @@ export const migrations = [
{ {
up: migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors.up, up: migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors.up,
down: migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors.down, down: migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors.down,
name: '20260809_204728_add_campaign_user_contact_and_leadership_advisors' name: '20260809_204728_add_campaign_user_contact_and_leadership_advisors',
},
{
up: migration_20260810_010844_add_activity_all_day.up,
down: migration_20260810_010844_add_activity_all_day.down,
name: '20260810_010844_add_activity_all_day',
}, },
]; ];

View file

@ -744,6 +744,10 @@ export interface Activity {
* Marque quando o deputado Jorge Solla estiver presente na atividade. * Marque quando o deputado Jorge Solla estiver presente na atividade.
*/ */
deputyPresent?: boolean | null; deputyPresent?: boolean | null;
/**
* Compromisso que ocupa um ou mais dias inteiros, sem horário.
*/
allDay?: boolean | null;
startAt?: string | null; startAt?: string | null;
endAt?: string | null; endAt?: string | null;
municipality: number | Municipality; municipality: number | Municipality;
@ -1799,6 +1803,7 @@ export interface ActivitySelect<T extends boolean = true> {
status?: T; status?: T;
description?: T; description?: T;
deputyPresent?: T; deputyPresent?: T;
allDay?: T;
startAt?: T; startAt?: T;
endAt?: T; endAt?: T;
municipality?: T; municipality?: T;

View file

@ -1,5 +1,6 @@
import 'server-only' import 'server-only'
import { allDayStartInstant, isCivilDate } from '@/lib/activityAllDay'
import { parseBahiaDateTimeInput } from '@/lib/campaignTime' import { parseBahiaDateTimeInput } from '@/lib/campaignTime'
import { import {
boundedJsonFormValue, boundedJsonFormValue,
@ -34,9 +35,19 @@ type ParsedActivityTask = {
done?: boolean done?: boolean
} }
const parseDateTimeFormField = (formData: FormData, field: string): string | undefined => { const parseScheduleFormField = (
formData: FormData,
field: string,
allDay: boolean,
): string | undefined => {
const value = optionalFormText(formData, field) const value = optionalFormText(formData, field)
if (!value) return undefined if (!value) return undefined
if (allDay) {
// C104 — with the toggle on the inputs are date-only civil values; the
// stored instants come from the all-day convention (00:00 Bahia).
if (!isCivilDate(value)) throw new FormDataBoundaryError(field, 'Data inválida.')
return allDayStartInstant(value)
}
const iso = parseBahiaDateTimeInput(value) const iso = parseBahiaDateTimeInput(value)
if (!iso) throw new FormDataBoundaryError(field, 'Data e horários inválidos.') if (!iso) throw new FormDataBoundaryError(field, 'Data e horários inválidos.')
return iso return iso
@ -151,14 +162,16 @@ const parseResponsiblesFormData = (formData: FormData): ParsedResponsible[] => {
} }
const parseSharedActivityFormData = (formData: FormData) => { const parseSharedActivityFormData = (formData: FormData) => {
const allDay = checkboxFormValue(formData, 'allDay')
return { return {
title: optionalFormText(formData, 'title') ?? '', title: optionalFormText(formData, 'title') ?? '',
tags: parseTagsFormData(formData), tags: parseTagsFormData(formData),
status: optionalFormText(formData, 'status') as (typeof activityStatuses)[number] | undefined, status: optionalFormText(formData, 'status') as (typeof activityStatuses)[number] | undefined,
description: optionalFormText(formData, 'description'), description: optionalFormText(formData, 'description'),
deputyPresent: checkboxFormValue(formData, 'deputyPresent'), deputyPresent: checkboxFormValue(formData, 'deputyPresent'),
startAt: parseDateTimeFormField(formData, 'startAt'), allDay,
endAt: parseDateTimeFormField(formData, 'endAt'), startAt: parseScheduleFormField(formData, 'startAt', allDay),
endAt: parseScheduleFormField(formData, 'endAt', allDay),
municipality: requiredRelationshipFormValue(formData, 'municipality'), municipality: requiredRelationshipFormValue(formData, 'municipality'),
locality: optionalFormText(formData, 'locality'), locality: optionalFormText(formData, 'locality'),
organizations: repeatedRelationshipFormValues(formData, 'organizations'), organizations: repeatedRelationshipFormValues(formData, 'organizations'),

View file

@ -1,5 +1,6 @@
import type { Where } from 'payload' import type { Where } from 'payload'
import { allDayStartInstant } from '@/lib/activityAllDay'
import { CAMPAIGN_AGENDA_HOME } from '@/lib/campaignPaths' import { CAMPAIGN_AGENDA_HOME } from '@/lib/campaignPaths'
import { import {
formatBahiaCivilDate, formatBahiaCivilDate,
@ -282,6 +283,8 @@ export type ActivityCreatePrefill = {
municipalityId?: number municipalityId?: number
title?: string title?: string
tags?: string[] tags?: string[]
/** C104 — the sheet opened the toggle; startAt/endAt are civil dates. */
allDay?: boolean
} }
export const buildActivityCreateHref = ( export const buildActivityCreateHref = (
@ -289,8 +292,14 @@ export const buildActivityCreateHref = (
prefill: Partial<ActivityCreatePrefill> = {}, prefill: Partial<ActivityCreatePrefill> = {},
): string => { ): string => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (prefill.startAt) params.set('startAt', prefill.startAt) if (prefill.allDay) {
if (prefill.endAt) params.set('endAt', prefill.endAt) params.set('allDay', '1')
if (prefill.startAt) params.set('startAt', prefill.startAt.slice(0, 10))
if (prefill.endAt) params.set('endAt', prefill.endAt.slice(0, 10))
} else {
if (prefill.startAt) params.set('startAt', prefill.startAt)
if (prefill.endAt) params.set('endAt', prefill.endAt)
}
const municipalityId = prefill.municipalityId ?? agendaState.municipality const municipalityId = prefill.municipalityId ?? agendaState.municipality
if (municipalityId) params.set('municipality', String(municipalityId)) if (municipalityId) params.set('municipality', String(municipalityId))
if (prefill.title) params.set('title', prefill.title) if (prefill.title) params.set('title', prefill.title)
@ -385,9 +394,17 @@ export const parseActivityCreatePrefill = (
params: RawSearchParams, params: RawSearchParams,
accessibleMunicipalityIDs: ReadonlySet<number>, accessibleMunicipalityIDs: ReadonlySet<number>,
): ActivityCreatePrefill => { ): ActivityCreatePrefill => {
const startAt = normalizedIsoInstant(firstValue(params.startAt)) const rawAllDay = firstValue(params.allDay)
const rawEndAt = normalizedIsoInstant(firstValue(params.endAt)) const allDay = rawAllDay === '1'
const endAt = startAt && rawEndAt && new Date(rawEndAt) > new Date(startAt) ? rawEndAt : undefined const startAt = allDay
? civilDatePrefillInstant(firstValue(params.startAt))
: normalizedIsoInstant(firstValue(params.startAt))
const rawEndAt = firstValue(params.endAt)
const endAt = allDay
? civilDatePrefillInstant(rawEndAt, startAt)
: startAt && rawEndAt && new Date(rawEndAt) > new Date(startAt)
? normalizedIsoInstant(rawEndAt)
: undefined
const rawMunicipality = strictDecimalInteger(firstValue(params.municipality)) const rawMunicipality = strictDecimalInteger(firstValue(params.municipality))
const municipalityId = const municipalityId =
rawMunicipality && accessibleMunicipalityIDs.has(rawMunicipality) ? rawMunicipality : undefined rawMunicipality && accessibleMunicipalityIDs.has(rawMunicipality) ? rawMunicipality : undefined
@ -397,6 +414,7 @@ export const parseActivityCreatePrefill = (
const tags = parseActivityPrefillTags(params.tags) const tags = parseActivityPrefillTags(params.tags)
return { return {
...(allDay ? { allDay } : {}),
...(startAt ? { startAt } : {}), ...(startAt ? { startAt } : {}),
...(endAt ? { endAt } : {}), ...(endAt ? { endAt } : {}),
...(municipalityId ? { municipalityId } : {}), ...(municipalityId ? { municipalityId } : {}),
@ -405,6 +423,16 @@ export const parseActivityCreatePrefill = (
} }
} }
const civilDatePrefillInstant = (
value: string | undefined,
startAt?: string,
): string | undefined => {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined
const instant = allDayStartInstant(value)
if (startAt && instant < startAt) return undefined
return instant
}
const activityListParamNames = ['q', 'tab', 'tag', 'status', 'municipality', 'page'] as const const activityListParamNames = ['q', 'tab', 'tag', 'status', 'municipality', 'page'] as const
const activityListParamNameSet = new Set<string>(activityListParamNames) const activityListParamNameSet = new Set<string>(activityListParamNames)

View file

@ -1,3 +1,4 @@
import { formatAllDayRangeLabel } from '@/lib/activityAllDay'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { isPopulatedRelationship, populatedContactName, relationshipId } from '@/lib/relationship' import { isPopulatedRelationship, populatedContactName, relationshipId } from '@/lib/relationship'
import { import {
@ -85,14 +86,21 @@ const mapActivityResponsibles = (
})) }))
} }
export const formatActivityWhenLabel = (startAt: string | null | undefined): string => export const formatActivityWhenLabel = (
startAt ? formatBahiaDateTimeLabel(startAt) : 'Data a definir' startAt: string | null | undefined,
options: { allDay?: boolean | null; endAt?: string | null } = {},
): string => {
if (!startAt) return 'Data a definir'
if (options.allDay) return formatAllDayRangeLabel(startAt, options.endAt)
return formatBahiaDateTimeLabel(startAt)
}
export const formatActivityHomeSearchSecondary = ( export const formatActivityHomeSearchSecondary = (
municipalityName: string | null, municipalityName: string | null,
startAt: string | null | undefined, startAt: string | null | undefined,
options: { allDay?: boolean | null; endAt?: string | null } = {},
): string => { ): string => {
const whenLabel = formatActivityWhenLabel(startAt) const whenLabel = formatActivityWhenLabel(startAt, options)
if (!municipalityName) return whenLabel if (!municipalityName) return whenLabel
return `${municipalityName} · ${whenLabel}` return `${municipalityName} · ${whenLabel}`
} }
@ -112,6 +120,7 @@ export const activityAgendaSelect = {
tags: true, tags: true,
status: true, status: true,
deputyPresent: true, deputyPresent: true,
allDay: true,
startAt: true, startAt: true,
endAt: true, endAt: true,
municipality: true, municipality: true,
@ -132,6 +141,7 @@ export type ActivityAgendaEvent = {
tags: string[] tags: string[]
status: Activity['status'] status: Activity['status']
deputyPresent: boolean deputyPresent: boolean
allDay: boolean
startAt: string startAt: string
endAt: string | null endAt: string | null
municipality: ActivityMunicipalitySummary | null municipality: ActivityMunicipalitySummary | null
@ -155,6 +165,7 @@ export const toActivityAgendaEvent = (
tags: activity.tags ?? [], tags: activity.tags ?? [],
status: activity.status, status: activity.status,
deputyPresent, deputyPresent,
allDay: Boolean(activity.allDay),
startAt: activity.startAt, startAt: activity.startAt,
endAt: activity.endAt ?? null, endAt: activity.endAt ?? null,
municipality: activityMunicipalitySummary(activity.municipality), municipality: activityMunicipalitySummary(activity.municipality),
@ -170,6 +181,7 @@ export type ActivityListViewModel = {
tags: string[] tags: string[]
status: Activity['status'] status: Activity['status']
deputyPresent: boolean deputyPresent: boolean
allDay: boolean
startAt: string | null startAt: string | null
endAt: string | null endAt: string | null
municipalityName: string | null municipalityName: string | null
@ -193,6 +205,7 @@ export const toActivityListViewModel = (activity: Activity): ActivityListViewMod
tags: activity.tags ?? [], tags: activity.tags ?? [],
status: activity.status, status: activity.status,
deputyPresent: Boolean(activity.deputyPresent), deputyPresent: Boolean(activity.deputyPresent),
allDay: Boolean(activity.allDay),
startAt: activity.startAt ?? null, startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null, endAt: activity.endAt ?? null,
municipalityName, municipalityName,
@ -213,6 +226,7 @@ export const activityFormSelect = {
status: true, status: true,
description: true, description: true,
deputyPresent: true, deputyPresent: true,
allDay: true,
startAt: true, startAt: true,
endAt: true, endAt: true,
municipality: true, municipality: true,
@ -238,6 +252,7 @@ export type ActivityFormViewModel = {
status: Activity['status'] status: Activity['status']
description: string | null description: string | null
deputyPresent: boolean deputyPresent: boolean
allDay: boolean
startAt: string | null startAt: string | null
endAt: string | null endAt: string | null
municipalityId: number | null municipalityId: number | null
@ -258,6 +273,7 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
status: activity.status, status: activity.status,
description: activity.description ?? null, description: activity.description ?? null,
deputyPresent: Boolean(activity.deputyPresent), deputyPresent: Boolean(activity.deputyPresent),
allDay: Boolean(activity.allDay),
startAt: activity.startAt ?? null, startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null, endAt: activity.endAt ?? null,
municipalityId: relationshipId(activity.municipality), municipalityId: relationshipId(activity.municipality),
@ -286,6 +302,7 @@ const activityDetailContextSelect = {
status: true, status: true,
description: true, description: true,
deputyPresent: true, deputyPresent: true,
allDay: true,
startAt: true, startAt: true,
endAt: true, endAt: true,
municipality: true, municipality: true,
@ -351,6 +368,7 @@ export type ActivityDetailViewModel = {
status: Activity['status'] status: Activity['status']
description: string | null description: string | null
deputyPresent: boolean deputyPresent: boolean
allDay: boolean
startAt: string | null startAt: string | null
endAt: string | null endAt: string | null
municipality: ActivityMunicipalitySummary | null municipality: ActivityMunicipalitySummary | null
@ -418,6 +436,7 @@ export const toActivityDetailViewModel = (
status: activity.status, status: activity.status,
description: activity.description ?? null, description: activity.description ?? null,
deputyPresent: Boolean(activity.deputyPresent), deputyPresent: Boolean(activity.deputyPresent),
allDay: Boolean(activity.allDay),
startAt: activity.startAt ?? null, startAt: activity.startAt ?? null,
endAt: activity.endAt ?? null, endAt: activity.endAt ?? null,
municipality, municipality,

View file

@ -1,5 +1,6 @@
import 'server-only' import 'server-only'
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
import type { Activity, CalendarFeed } from '@/payload-types' import type { Activity, CalendarFeed } from '@/payload-types'
import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared' import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared'
import type { Payload, Where } from 'payload' import type { Payload, Where } from 'payload'
@ -49,16 +50,22 @@ export const generateICalFeed = (
const dtStart = formatICalDate(activity.startAt) const dtStart = formatICalDate(activity.startAt)
const dtEnd = activity.endAt ? formatICalDate(activity.endAt) : dtStart const dtEnd = activity.endAt ? formatICalDate(activity.endAt) : dtStart
// C104 — all-day commitments export as date-only values (VALUE=DATE) with
// the exclusive end (day after the last day), the iCal all-day convention.
const allDay = Boolean(activity.allDay)
const dtStartLine = allDay
? `DTSTART;VALUE=DATE:${formatICalDate(allDayCivilDateOf(activity.startAt))}`
: `DTSTART:${dtStart}`
const dtEndLine = allDay
? `DTEND;VALUE=DATE:${formatICalDate(allDayExclusiveEndDate(activity.endAt ?? activity.startAt))}`
: `DTEND:${activity.endAt ? dtEnd : dtStart}`
const summary = municipalityName ? `[${municipalityName}] ${activity.title}` : activity.title const summary = municipalityName ? `[${municipalityName}] ${activity.title}` : activity.title
lines.push('BEGIN:VEVENT') lines.push('BEGIN:VEVENT')
lines.push(`UID:${activity.slug}@teqo.jorgesolla.com.br`) lines.push(`UID:${activity.slug}@teqo.jorgesolla.com.br`)
lines.push(`DTSTART:${dtStart}`) lines.push(dtStartLine)
if (activity.endAt) { lines.push(dtEndLine)
lines.push(`DTEND:${dtEnd}`)
} else {
lines.push(`DTEND:${dtStart}`)
}
lines.push(`SUMMARY:${escapeICalText(summary)}`) lines.push(`SUMMARY:${escapeICalText(summary)}`)
lines.push(`DESCRIPTION:${buildActivityDescription(activity, municipalityName)}`) lines.push(`DESCRIPTION:${buildActivityDescription(activity, municipalityName)}`)
lines.push(`DTSTAMP:${formatICalDate(activity.updatedAt || activity.createdAt)}`) lines.push(`DTSTAMP:${formatICalDate(activity.updatedAt || activity.createdAt)}`)

View file

@ -46,7 +46,9 @@ export const searchHomeActivities = async (
id: true, id: true,
title: true, title: true,
slug: true, slug: true,
allDay: true,
startAt: true, startAt: true,
endAt: true,
municipality: true, municipality: true,
}, },
user, user,
@ -76,7 +78,10 @@ export const searchHomeActivities = async (
id: doc.id, id: doc.id,
slug: doc.slug, slug: doc.slug,
title: doc.title, title: doc.title,
secondary: formatActivityHomeSearchSecondary(municipalityName, doc.startAt), secondary: formatActivityHomeSearchSecondary(municipalityName, doc.startAt, {
allDay: doc.allDay,
endAt: doc.endAt,
}),
}, },
}) })
} }

View file

@ -251,6 +251,72 @@ test.describe('Agenda — calendário operacional', () => {
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*')) await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
}) })
test('cria compromisso "Todo o dia" no slot, sem horário e com faixa no calendário (C104)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
const title = fixtures.value('Giro de dia inteiro')
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
const dayCell = page.getByRole('gridcell').nth(1)
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.')
await dayCell.click({
position: {
x: dayBox.width / 2,
y: slotBox.y - dayBox.y + slotBox.height / 2,
},
})
const startTrigger = page.getByLabel('Início *')
await expect(startTrigger).toBeVisible()
await expect(startTrigger).toHaveText(/\d{2}\/\d{2}\/\d{4} às 14:00/)
// C104 — toggling "Todo o dia" hides the times: both triggers show only
// the civil date, and the picker no longer offers Hora/Minuto selects.
await page.getByLabel('Todo o dia').click()
await expect(startTrigger).toHaveText(/^\d{2}\/\d{2}\/\d{4}$/)
await expect(page.getByLabel('Término')).toHaveText(/^\d{2}\/\d{2}\/\d{4}$/)
await startTrigger.click()
await expect(
page.locator('[data-slot="popover-content"][data-state="open"]').last().getByRole('combobox'),
).toHaveCount(0)
await page.keyboard.press('Escape')
// Multi-day: the end picker lands on a later day of the same month.
await page.getByLabel('Término').click()
const endPicker = page.locator('[data-slot="popover-content"][data-state="open"]').last()
await endPicker.getByRole('button', { name: /17 de agosto de 2026/ }).click()
await page.keyboard.press('Escape')
await expect(page.getByLabel('Término')).toHaveText(/17\/08\/2026/)
await page.getByLabel('Título *').fill(title)
await page.getByRole('button', { name: 'Salvar' }).click()
// The band lands without a time chip: the all-day event carries no
// "14:00 – 14:30" text (the timed events render it; allDay:true does not).
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
await expect(page.getByText(/\d{2}:\d{2} – \d{2}:\d{2}/)).toHaveCount(0)
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
// The detail page preserves the all-day choice: date-only labels.
await page.getByText(title, { exact: true }).first().click()
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/)
await expect(page.getByText(/^\d{2}\/\d{2}\/\d{4}$/).first()).toBeVisible()
await expect(page.getByText(/\d{2}\/\d{2}\/\d{4} às /)).toHaveCount(0)
})
test('"Mais detalhes" pré-preenche o formulário completo com o rascunho', async ({ test('"Mais detalhes" pré-preenche o formulário completo com o rascunho', async ({
campaign, campaign,
page, page,

View file

@ -14,6 +14,7 @@ import {
loadActivityAgendaEventsRecord, loadActivityAgendaEventsRecord,
rescheduleActivityRecord, rescheduleActivityRecord,
} from '@/app/(campaign)/campanha/actions/activity' } from '@/app/(campaign)/campanha/actions/activity'
import { allDayEndInstant, allDayStartInstant } from '@/lib/activityAllDay'
import { relationshipId } from '@/lib/relationship' import { relationshipId } from '@/lib/relationship'
import { import {
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE, ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
@ -483,6 +484,133 @@ describe('activity domain', () => {
).rejects.toThrow('O horário de término deve ser posterior ao de início.') ).rejects.toThrow('O horário de término deve ser posterior ao de início.')
}) })
it('creates a multi-day all-day commitment with day-boundary instants (C104)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const startAt = allDayStartInstant('2026-08-10')
const endAt = allDayEndInstant('2026-08-12')
const activity = await createActivityRecord(payload, coordinator, {
title: fixtures.value('Giro de três dias'),
tags: ['Giro'],
status: 'confirmado',
allDay: true,
startAt,
endAt,
municipality: municipality.id,
})
expect(activity.allDay).toBe(true)
expect(activity.startAt).toBe(startAt)
expect(activity.endAt).toBe(endAt)
const events = await loadActivityAgendaEventsRecord(payload, coordinator, {
rangeStart: '2026-08-09T03:00:00.000Z',
rangeEnd: '2026-08-14T03:00:00.000Z',
})
const event = events.find((candidate) => candidate.id === activity.id)
expect(event?.allDay).toBe(true)
expect(event?.startAt).toBe(startAt)
expect(event?.endAt).toBe(endAt)
})
it('accepts a single-day all-day range with equal instants (C104)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const startAt = allDayStartInstant('2026-08-10')
const activity = await createActivityRecord(payload, coordinator, {
title: fixtures.value('Dia inteiro único'),
status: 'confirmado',
allDay: true,
startAt,
endAt: startAt,
municipality: municipality.id,
})
expect(activity.allDay).toBe(true)
expect(activity.endAt).toBe(startAt)
})
it('rejects an all-day range whose end precedes the start in the collection hook (C104)', async () => {
const fixtures = campaignFixtures()
const municipality = await fixtures.getMunicipality()
await expect(
payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(municipality.id),
title: fixtures.value('Todo o dia invertido'),
allDay: true,
startAt: allDayStartInstant('2026-08-12'),
endAt: allDayStartInstant('2026-08-10'),
}),
overrideAccess: true,
}),
).rejects.toThrow('A data de término deve ser igual ou posterior à de início.')
})
it('reschedules an all-day commitment keeping the day granularity (C104)', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
const activity = await payload.create({
collection: 'activity',
data: stub<ActivityCreateData>({
...validActivityInput(municipality.id),
title: fixtures.value('Todo o dia remarcável'),
allDay: true,
startAt: allDayStartInstant('2026-08-10'),
endAt: allDayEndInstant('2026-08-12'),
}),
overrideAccess: true,
})
fixtures.own('activity', activity.id)
const nextStart = allDayStartInstant('2026-08-20')
const nextEnd = allDayEndInstant('2026-08-20')
const updated = await rescheduleActivityRecord(payload, coordinator, {
id: activity.id,
allDay: true,
startAt: nextStart,
endAt: nextEnd,
})
expect(updated.allDay).toBe(true)
expect(updated.startAt).toBe(nextStart)
expect(updated.endAt).toBe(nextEnd)
await expect(
rescheduleActivityRecord(payload, coordinator, {
id: activity.id,
allDay: true,
startAt: allDayStartInstant('2026-08-25'),
endAt: allDayStartInstant('2026-08-24'),
}),
).rejects.toThrow()
})
it('parses date-only form values into all-day instants (C104)', () => {
const formData = new FormData()
formData.set('title', 'Caminhada no centro')
formData.set('status', 'confirmado')
formData.set('allDay', 'on')
formData.set('municipality', '1')
formData.set('startAt', '2026-08-10')
formData.set('endAt', '2026-08-12')
formData.set('tagsJson', '[]')
formData.set('demandsJson', '[]')
const parsed = parseActivityCreateFormData(formData)
expect(parsed.allDay).toBe(true)
expect(parsed.startAt).toBe(allDayStartInstant('2026-08-10'))
expect(parsed.endAt).toBe(allDayEndInstant('2026-08-12'))
})
it('loads only accessible events matching the agenda range and filters', async () => { it('loads only accessible events matching the agenda range and filters', async () => {
const fixtures = campaignFixtures() const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor') const advisor = await fixtures.createCampaignUser('advisor')
@ -554,6 +682,7 @@ describe('activity domain', () => {
const nextStart = new Date(Date.now() + 172_800_000).toISOString() const nextStart = new Date(Date.now() + 172_800_000).toISOString()
const updated = await rescheduleActivityRecord(payload, advisor, { const updated = await rescheduleActivityRecord(payload, advisor, {
allDay: false,
id: activity.id, id: activity.id,
startAt: nextStart, startAt: nextStart,
endAt: null, endAt: null,
@ -572,6 +701,7 @@ describe('activity domain', () => {
await expect( await expect(
rescheduleActivityRecord(payload, advisor, { rescheduleActivityRecord(payload, advisor, {
allDay: false,
id: activity.id, id: activity.id,
startAt: new Date(Date.now() + 259_200_000).toISOString(), startAt: new Date(Date.now() + 259_200_000).toISOString(),
endAt: null, endAt: null,
@ -609,6 +739,7 @@ describe('activity domain', () => {
] as const) { ] as const) {
const nextStart = new Date(Date.now() + offset).toISOString() const nextStart = new Date(Date.now() + offset).toISOString()
const updated = await rescheduleActivityRecord(payload, actor, { const updated = await rescheduleActivityRecord(payload, actor, {
allDay: false,
id: activity.id, id: activity.id,
startAt: nextStart, startAt: nextStart,
endAt: null, endAt: null,

View file

@ -59,11 +59,13 @@ describe('activity reschedule input', () => {
expect( expect(
activityRescheduleSchema.parse({ activityRescheduleSchema.parse({
id: 7, id: 7,
allDay: false,
startAt: '2026-08-07T10:00:00-03:00', startAt: '2026-08-07T10:00:00-03:00',
endAt: null, endAt: null,
}), }),
).toEqual({ ).toEqual({
id: 7, id: 7,
allDay: false,
startAt: '2026-08-07T13:00:00.000Z', startAt: '2026-08-07T13:00:00.000Z',
endAt: null, endAt: null,
}) })
@ -73,11 +75,50 @@ describe('activity reschedule input', () => {
expect( expect(
activityRescheduleSchema.safeParse({ activityRescheduleSchema.safeParse({
id: 7, id: 7,
allDay: false,
startAt: '2026-08-07T13:00:00.000Z', startAt: '2026-08-07T13:00:00.000Z',
endAt: '2026-08-07T12:59:00.000Z', endAt: '2026-08-07T12:59:00.000Z',
}).success, }).success,
).toBe(false) ).toBe(false)
}) })
it('accepts a single-day all-day range (end equals start)', () => {
expect(
activityRescheduleSchema.parse({
id: 7,
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-10T03:00:00.000Z',
}),
).toEqual({
id: 7,
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-10T03:00:00.000Z',
})
})
it('rejects an all-day range whose end precedes the start', () => {
expect(
activityRescheduleSchema.safeParse({
id: 7,
allDay: true,
startAt: '2026-08-12T03:00:00.000Z',
endAt: '2026-08-10T03:00:00.000Z',
}).success,
).toBe(false)
})
it('requires an end for all-day events', () => {
expect(
activityRescheduleSchema.safeParse({
id: 7,
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: null,
}).success,
).toBe(false)
})
}) })
describe('activity reschedule policy', () => { describe('activity reschedule policy', () => {
@ -120,6 +161,7 @@ describe('activity agenda event view model', () => {
tags: ['Caminhada'], tags: ['Caminhada'],
status: 'confirmado', status: 'confirmado',
deputyPresent: false, deputyPresent: false,
allDay: false,
startAt: '2026-08-07T13:00:00.000Z', startAt: '2026-08-07T13:00:00.000Z',
endAt: null, endAt: null,
municipality: { id: 12, name: 'Ilhéus', slug: 'ilheus' }, municipality: { id: 12, name: 'Ilhéus', slug: 'ilheus' },
@ -128,6 +170,25 @@ describe('activity agenda event view model', () => {
}) })
}) })
it('carries the all-day flag for full-day commitments', () => {
expect(
toActivityAgendaEvent(
stub<Activity>({
id: 9,
title: 'Giro no interior',
slug: 'giro-no-interior',
tags: [],
status: 'confirmado',
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-12T03:00:00.000Z',
municipality: stub<Municipality>({ id: 12, name: 'Ilhéus', slug: 'ilheus' }),
}),
user('advisor'),
).allDay,
).toBe(true)
})
it('marks a deputy commitment read-only for an advisor', () => { it('marks a deputy commitment read-only for an advisor', () => {
expect( expect(
toActivityAgendaEvent( toActivityAgendaEvent(

View file

@ -140,6 +140,18 @@ describe('ActivityAgenda schedule failures', () => {
// toolbar and the calendar body are what these tests exercise). // toolbar and the calendar body are what these tests exercise).
beforeEach(() => { beforeEach(() => {
stubMatchMedia() stubMatchMedia()
// C104 — the toggle (radix Checkbox) measures its indicator via
// react-use-size, which needs ResizeObserver — same stub the
// inline-create spec uses. Stubbed per test because the afterEach below
// restores every stubGlobal.
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
}) })
afterEach(() => { afterEach(() => {

View file

@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import {
allDayCivilDateOf,
allDayEndInstant,
allDayEndInstantFromExclusive,
allDayExclusiveEndDate,
allDayRangeValid,
allDayStartInstant,
formatAllDayRangeLabel,
isCivilDate,
} from '@/lib/activityAllDay'
describe('activityAllDay civil date contract', () => {
it('recognizes only aaaa-mm-dd values', () => {
expect(isCivilDate('2026-08-10')).toBe(true)
expect(isCivilDate('2026-8-10')).toBe(false)
expect(isCivilDate('2026-08-10T09:00')).toBe(false)
expect(isCivilDate('')).toBe(false)
})
})
describe('activityAllDay storage instants', () => {
it('anchors the start on Bahia midnight (fixed −03, no DST)', () => {
expect(allDayStartInstant('2026-08-10')).toBe('2026-08-10T03:00:00.000Z')
})
it('anchors the inclusive end on the last day at Bahia midnight', () => {
expect(allDayEndInstant('2026-08-12')).toBe('2026-08-12T03:00:00.000Z')
})
it('round-trips a stored instant back to its civil date', () => {
expect(allDayCivilDateOf('2026-08-12T03:00:00.000Z')).toBe('2026-08-12')
})
it('rejects a malformed civil date defensively', () => {
expect(() => allDayStartInstant('10/08/2026')).toThrow()
})
})
describe('activityAllDay exclusive-end conversions (FullCalendar / iCal)', () => {
it('derives the exclusive end as the day after the last stored day', () => {
expect(allDayExclusiveEndDate('2026-08-12T03:00:00.000Z')).toBe('2026-08-13')
})
it('turns an exclusive end back into the stored inclusive instant', () => {
expect(allDayEndInstantFromExclusive('2026-08-13')).toBe('2026-08-12T03:00:00.000Z')
})
it('keeps single-day events stable across the round trip', () => {
const start = '2026-08-10T03:00:00.000Z'
expect(allDayEndInstantFromExclusive(allDayExclusiveEndDate(start))).toBe(start)
})
it('crosses month and year boundaries', () => {
expect(allDayExclusiveEndDate('2026-08-31T03:00:00.000Z')).toBe('2026-09-01')
expect(allDayExclusiveEndDate('2026-12-31T03:00:00.000Z')).toBe('2027-01-01')
expect(allDayEndInstantFromExclusive('2026-03-01')).toBe('2026-02-28T03:00:00.000Z')
})
})
describe('activityAllDay range rules', () => {
it('accepts a single-day range (end equals start)', () => {
const start = '2026-08-10T03:00:00.000Z'
expect(allDayRangeValid(start, start)).toBe(true)
})
it('accepts a multi-day range and rejects an inverted one', () => {
const start = '2026-08-10T03:00:00.000Z'
const end = '2026-08-12T03:00:00.000Z'
expect(allDayRangeValid(start, end)).toBe(true)
expect(allDayRangeValid(end, start)).toBe(false)
})
})
describe('formatAllDayRangeLabel', () => {
it('prints a single civil date for a one-day commitment', () => {
expect(formatAllDayRangeLabel('2026-08-10T03:00:00.000Z')).toBe('10/08/2026')
})
it('prints the full range for a multi-day commitment', () => {
expect(formatAllDayRangeLabel('2026-08-10T03:00:00.000Z', '2026-08-12T03:00:00.000Z')).toBe(
'10/08/2026 a 12/08/2026',
)
})
it('collapses a same-day range to the single date', () => {
expect(formatAllDayRangeLabel('2026-08-10T03:00:00.000Z', '2026-08-10T03:00:00.000Z')).toBe(
'10/08/2026',
)
})
})

View file

@ -285,4 +285,93 @@ describe('ActivityInlineCreate — overlay de criação inline', () => {
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026 às 13:00') expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026 às 13:00')
expect(screen.getByRole('button', { name: 'Salvar' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Salvar' })).toBeTruthy()
}) })
it('alterna "Todo o dia" e esconde os seletores de horário (C104)', () => {
renderOverlay({ agendaState: { municipality: 12 } })
fireEvent.click(screen.getByLabelText('Todo o dia'))
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026')
expect(screen.getByLabelText('Término').textContent).toBe('07/08/2026')
fireEvent.click(screen.getByLabelText('Início *'))
const dialogs = screen.getAllByRole('dialog')
const picker = within(dialogs[dialogs.length - 1])
expect(picker.queryByRole('combobox', { name: 'Hora' })).toBeNull()
expect(picker.queryByRole('combobox', { name: 'Minuto' })).toBeNull()
fireEvent.click(screen.getByLabelText('Todo o dia'))
expect(screen.getByLabelText('Início *').textContent).toBe('07/08/2026 às 13:00')
})
it('salva dia inteiro com instantes de meia-noite baiana (C104)', async () => {
const onCreated = vi.fn()
mocks.createInline.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: { municipality: 12 }, onCreated })
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Café com apoiadores' },
})
fireEvent.click(screen.getByLabelText('Todo o dia'))
fireEvent.click(screen.getByLabelText('Início *'))
const dialogs = screen.getAllByRole('dialog')
const startPicker = within(dialogs[dialogs.length - 1])
fireEvent.click(startPicker.getByRole('button', { name: /15 de agosto de 2026/ }))
fireEvent.click(screen.getByLabelText('Término'))
const endPicker = within(
screen.getAllByRole('dialog')[screen.getAllByRole('dialog').length - 1],
)
fireEvent.click(endPicker.getByRole('button', { name: /17 de agosto de 2026/ }))
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() => expect(mocks.createInline).toHaveBeenCalledTimes(1))
expect(mocks.createInline).toHaveBeenCalledWith({
title: 'Café com apoiadores',
municipality: 12,
allDay: true,
startAt: '2026-08-15T03:00:00.000Z',
endAt: '2026-08-17T03:00:00.000Z',
})
expect(onCreated).toHaveBeenCalledTimes(1)
})
it('bloqueia dia inteiro com término anterior ao início (C104)', async () => {
mocks.createInline.mockResolvedValue({ ok: true })
renderOverlay({ agendaState: { municipality: 12 } })
fireEvent.change(screen.getByLabelText('Título *'), {
target: { value: 'Café com apoiadores' },
})
fireEvent.click(screen.getByLabelText('Todo o dia'))
fireEvent.click(screen.getByLabelText('Início *'))
const startPicker = within(screen.getAllByRole('dialog').at(-1) as HTMLElement)
fireEvent.click(startPicker.getByRole('button', { name: /15 de agosto de 2026/ }))
fireEvent.click(screen.getByLabelText('Término'))
const endPicker = within(screen.getAllByRole('dialog').at(-1) as HTMLElement)
fireEvent.click(endPicker.getByRole('button', { name: /10 de agosto de 2026/ }))
fireEvent.click(screen.getByRole('button', { name: 'Salvar' }))
await waitFor(() =>
expect(
screen.getByText('A data de término deve ser igual ou posterior à de início.'),
).toBeTruthy(),
)
expect(mocks.createInline).not.toHaveBeenCalled()
})
it('passa "Todo o dia" e as datas para "Mais detalhes" (C104)', () => {
renderOverlay({ agendaState: { municipality: 12 } })
fireEvent.click(screen.getByLabelText('Todo o dia'))
const link = screen.getByRole('link', { name: 'Mais detalhes' })
expect(link.getAttribute('href')).toBe(
'/campanha/atividades/nova?allDay=1&startAt=2026-08-07&endAt=2026-08-07&municipality=12&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
)
})
}) })

View file

@ -441,6 +441,78 @@ describe('activity create prefill', () => {
tags: ['válida'], tags: ['válida'],
}) })
}) })
it('carries the all-day choice as civil dates into the creation URL (C104)', () => {
expect(
buildActivityCreateHref(
{ municipality: 12 },
{
allDay: true,
startAt: '2026-08-10',
endAt: '2026-08-12',
municipalityId: 12,
},
),
).toBe(
'/campanha/atividades/nova?allDay=1&startAt=2026-08-10&endAt=2026-08-12&municipality=12&returnTo=%2Fcampanha%2Fagenda%3Fmunicipality%3D12',
)
})
it('parses an all-day prefill into day-boundary instants', () => {
expect(
parseActivityCreatePrefill(
{
allDay: '1',
startAt: '2026-08-10',
endAt: '2026-08-12',
municipality: '12',
},
new Set([12]),
),
).toEqual({
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-12T03:00:00.000Z',
municipalityId: 12,
})
})
it('allows a single-day all-day prefill (end equals start)', () => {
expect(
parseActivityCreatePrefill(
{
allDay: '1',
startAt: '2026-08-10',
endAt: '2026-08-10',
},
new Set(),
),
).toEqual({
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-10T03:00:00.000Z',
})
})
it('drops an inverted all-day end and malformed all-day dates', () => {
expect(
parseActivityCreatePrefill(
{
allDay: '1',
startAt: '2026-08-12',
endAt: '2026-08-10',
},
new Set(),
),
).toEqual({ allDay: true, startAt: '2026-08-12T03:00:00.000Z' })
expect(
parseActivityCreatePrefill(
{ allDay: '1', startAt: '10/08/2026', endAt: '2026-08-10' },
new Set(),
),
).toEqual({ allDay: true, endAt: '2026-08-10T03:00:00.000Z' })
expect(parseActivityCreatePrefill({ allDay: '0' }, new Set())).toEqual({})
})
}) })
describe('activity slot prefill (C91)', () => { describe('activity slot prefill (C91)', () => {

View file

@ -59,6 +59,45 @@ describe('generateICalFeed', () => {
expect(result).not.toContain('evento-cancelado') expect(result).not.toContain('evento-cancelado')
}) })
it('exports all-day commitments as date values with the exclusive end (C104)', () => {
const activities = [
{
id: 2,
slug: 'giro-interior',
title: 'Giro no interior',
status: 'confirmado' as const,
allDay: true,
startAt: '2026-08-10T03:00:00.000Z',
endAt: '2026-08-12T03:00:00.000Z',
municipality: 2,
tags: ['giro'],
deputyPresent: true,
updatedAt: '2026-08-01T10:00:00.000Z',
createdAt: '2026-08-01T10:00:00.000Z',
},
{
id: 3,
slug: 'dia-inteiro-avulso',
title: 'Dia inteiro avulso',
status: 'confirmado' as const,
allDay: true,
startAt: '2026-08-20T03:00:00.000Z',
endAt: '2026-08-20T03:00:00.000Z',
municipality: 1,
updatedAt: '2026-08-01T10:00:00.000Z',
createdAt: '2026-08-01T10:00:00.000Z',
},
] as unknown as Parameters<typeof generateICalFeed>[0]
const result = generateICalFeed(activities, 'Test', municipalityNames)
expect(result).toContain('DTSTART;VALUE=DATE:20260810')
expect(result).toContain('DTEND;VALUE=DATE:20260813')
expect(result).toContain('DTSTART;VALUE=DATE:20260820')
expect(result).toContain('DTEND;VALUE=DATE:20260821')
expect(result).not.toContain('DTSTART:20260810')
})
it('escapes special characters in text fields', () => { it('escapes special characters in text fields', () => {
const activities = [ const activities = [
{ {