mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #537 from fsolla/C104-agenda-compromisso-todo-o-dia-na-criacao-rapida-so-data
C104 — Compromisso 'Todo o dia' na criação rápida da agenda (só datas, multi-dia)
This commit is contained in:
commit
c8e009b47a
31 changed files with 14402 additions and 141 deletions
|
|
@ -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):** **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):** **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):** **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).
|
||||
|
||||
|
|
|
|||
127
docs/plans/agenda-toggle-todo-o-dia-impl.md
Normal file
127
docs/plans/agenda-toggle-todo-o-dia-impl.md
Normal 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
|
||||
|
|
@ -6,6 +6,7 @@ import { CampaignListPagination } from '@/components/campaign/shared/CampaignLis
|
|||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatAllDayRangeLabel } from '@/lib/activityAllDay'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { campaignDemandKindLabels, campaignDemandStatusLabels } from '@/lib/schemas/campaignDemand'
|
||||
import type { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
|
||||
|
|
@ -65,11 +66,23 @@ export const ActivityOverviewTab = ({
|
|||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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 className="sm:col-span-2">
|
||||
<dt className="text-muted-foreground">Tags</dt>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { Badge } from '@/components/ui/Badge'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { formatActivityWhenLabel } from '@/utilities/activityViewModels'
|
||||
|
||||
import { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
|
||||
import {
|
||||
|
|
@ -115,7 +115,9 @@ export default async function ActivityDetailPage({
|
|||
</Badge>
|
||||
))}
|
||||
<span className="text-muted-foreground">
|
||||
{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'}
|
||||
{view.startAt
|
||||
? formatActivityWhenLabel(view.startAt, { allDay: view.allDay, endAt: view.endAt })
|
||||
: 'Data a definir'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
} from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import { allDayRangeValid } from '@/lib/activityAllDay'
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
ACTIVITY_DEPUTY_RESCHEDULE_FORBIDDEN_MESSAGE,
|
||||
|
|
@ -46,6 +47,7 @@ const activityStaffFieldSnapshot = (doc: Record<string, unknown>) => ({
|
|||
tags: Array.isArray(doc.tags) ? [...doc.tags].sort() : [],
|
||||
status: doc.status ?? null,
|
||||
description: trimmedText(doc.description),
|
||||
allDay: Boolean(doc.allDay),
|
||||
startAt: doc.startAt ?? null,
|
||||
endAt: doc.endAt ?? null,
|
||||
municipality: relationshipId(doc.municipality),
|
||||
|
|
@ -96,16 +98,29 @@ const validateActivitySchedule: CollectionBeforeValidateHook = ({
|
|||
const nextData = operation === 'update' ? { ...originalDoc, ...data } : data
|
||||
const startAt = nextData.startAt ?? null
|
||||
const endAt = nextData.endAt ?? null
|
||||
const allDay = Boolean(nextData.allDay)
|
||||
|
||||
// C14: startAt is always required (no more rascunho without date).
|
||||
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) {
|
||||
const start = new Date(startAt 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -455,6 +470,21 @@ export const Activity: CollectionConfig = {
|
|||
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',
|
||||
type: 'date',
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ import { useAgendaSwipeNavigation } from '@/components/campaign/activity/useAgen
|
|||
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useIsMobileMeasured } from '@/hooks/use-mobile'
|
||||
import {
|
||||
allDayEndInstantFromExclusive,
|
||||
allDayExclusiveEndDate,
|
||||
allDayStartInstant,
|
||||
} from '@/lib/activityAllDay'
|
||||
import { formatBahiaCivilDate } from '@/lib/campaignTime'
|
||||
import {
|
||||
ACTIVITY_RESCHEDULE_FAILED_MESSAGE,
|
||||
|
|
@ -84,9 +89,13 @@ const AGENDA_DAY_SCROLL_TIME = '08:00:00'
|
|||
const toEventInput = (event: ActivityAgendaEvent): EventInput => ({
|
||||
id: String(event.id),
|
||||
title: event.title,
|
||||
start: event.startAt,
|
||||
end: event.endAt ?? undefined,
|
||||
allDay: false,
|
||||
// C104 — all-day events use the date parts: start as-is (first day), end as
|
||||
// the exclusive day after the last stored day (FullCalendar's convention).
|
||||
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,
|
||||
startEditable: event.canReschedule,
|
||||
durationEditable: event.canReschedule,
|
||||
|
|
@ -370,11 +379,13 @@ export const ActivityAgenda = ({
|
|||
const persistSchedule = useCallback(
|
||||
async ({
|
||||
id,
|
||||
allDay,
|
||||
startAt,
|
||||
endAt,
|
||||
revert,
|
||||
}: {
|
||||
id: string
|
||||
allDay: boolean
|
||||
startAt: string
|
||||
endAt: string | null
|
||||
revert: () => void
|
||||
|
|
@ -387,7 +398,7 @@ export const ActivityAgenda = ({
|
|||
pendingEventIDs.current.add(id)
|
||||
setSavingCount((count) => count + 1)
|
||||
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 (!result.ok) {
|
||||
|
|
@ -416,12 +427,24 @@ export const ActivityAgenda = ({
|
|||
|
||||
const handleScheduleChange = useCallback(
|
||||
(info: EventDropInfo | EventResizeDoneInfo) => {
|
||||
void persistSchedule({
|
||||
id: info.event.id,
|
||||
startAt: info.event.startStr,
|
||||
endAt: info.event.endStr || null,
|
||||
revert: info.revert,
|
||||
})
|
||||
// C104 — the all-day lane exists now; dragging an event between the lane
|
||||
// and the time grid would flip its allDay flag. Converting a commitment
|
||||
// by dragging is out of scope, so such drops are rejected.
|
||||
if ('oldEvent' in info && info.event.allDay !== info.oldEvent.allDay) {
|
||||
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],
|
||||
)
|
||||
|
|
@ -484,7 +507,8 @@ export const ActivityAgenda = ({
|
|||
slotMaxTime="22:00:00"
|
||||
scrollTime={AGENDA_DAY_SCROLL_TIME}
|
||||
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
|
||||
editable={!isLoading && savingCount === 0}
|
||||
eventLongPressDelay={650}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,12 @@ export const ActivityCard = ({ activity }: { activity: ActivityListViewModel })
|
|||
<CardContent className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<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>
|
||||
{activity.locationLabel ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover
|
|||
import {
|
||||
floorToMinuteStep,
|
||||
formatBahiaCivilDate,
|
||||
formatBahiaCivilDateLabel,
|
||||
formatBahiaCivilDateTimeLabel,
|
||||
hourOptions,
|
||||
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.
|
||||
* The parent renders the surrounding Field/FieldError; this component is the
|
||||
* 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 = ({
|
||||
id,
|
||||
|
|
@ -32,6 +38,7 @@ export const ActivityDateTimeField = ({
|
|||
onValueChange,
|
||||
invalid = false,
|
||||
errorId,
|
||||
timeVisible = true,
|
||||
}: {
|
||||
id: string
|
||||
value: string
|
||||
|
|
@ -39,6 +46,8 @@ export const ActivityDateTimeField = ({
|
|||
invalid?: boolean
|
||||
/** FieldError id to announce on focus — parity with the input it replaces. */
|
||||
errorId?: string
|
||||
/** When false, hide the time selects and label the trigger with the date only. */
|
||||
timeVisible?: boolean
|
||||
}) => {
|
||||
const civil = floorToMinuteStep(value)
|
||||
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',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{formatBahiaCivilDateTimeLabel(civil)}</span>
|
||||
<span className="truncate">
|
||||
{timeVisible ? formatBahiaCivilDateTimeLabel(civil) : formatBahiaCivilDateLabel(civil)}
|
||||
</span>
|
||||
<CalendarIcon
|
||||
data-icon="inline-end"
|
||||
aria-hidden="true"
|
||||
|
|
@ -93,38 +104,42 @@ export const ActivityDateTimeField = ({
|
|||
className="w-full p-2"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-3 border-t p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">Hora</span>
|
||||
<NativeSelect
|
||||
aria-label="Hora"
|
||||
value={hour}
|
||||
onChange={(event) => setHour(event.target.value)}
|
||||
>
|
||||
{hourOptions.map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}h
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">Minuto</span>
|
||||
<NativeSelect
|
||||
aria-label="Minuto"
|
||||
value={minute}
|
||||
onChange={(event) => setMinute(event.target.value)}
|
||||
>
|
||||
{minuteOptionsForStep().map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{timeVisible ? (
|
||||
<div className="flex flex-col gap-3 border-t p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">Hora</span>
|
||||
<NativeSelect
|
||||
aria-label="Hora"
|
||||
value={hour}
|
||||
onChange={(event) => setHour(event.target.value)}
|
||||
>
|
||||
{hourOptions.map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}h
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground select-none">
|
||||
Minuto
|
||||
</span>
|
||||
<NativeSelect
|
||||
aria-label="Minuto"
|
||||
value={minute}
|
||||
onChange={(event) => setMinute(event.target.value)}
|
||||
>
|
||||
{minuteOptionsForStep().map((option) => (
|
||||
<NativeSelectOption key={option} value={option}>
|
||||
{option}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useActionState } from 'react'
|
||||
import { useActionState, useState } from 'react'
|
||||
|
||||
import { ActivityDemandFields } from '@/components/campaign/activity/ActivityDemandFields'
|
||||
import { ActivityTagInput } from '@/components/campaign/activity/ActivityTagInput'
|
||||
|
|
@ -70,6 +70,20 @@ const ActivityFormFields = ({
|
|||
const initialStartAt = activity?.startAt ?? initialValues?.startAt
|
||||
const initialEndAt = activity?.endAt ?? initialValues?.endAt
|
||||
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 (
|
||||
<FieldGroup>
|
||||
|
|
@ -139,38 +153,79 @@ const ActivityFormFields = ({
|
|||
<CardTitle>Data e horário</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errorFor('startAt'))}>
|
||||
<FieldLabel htmlFor="startAt">Início *</FieldLabel>
|
||||
<Input
|
||||
id="startAt"
|
||||
name="startAt"
|
||||
type="datetime-local"
|
||||
defaultValue={initialStartAt ? formatIsoAsBahiaDateTimeInput(initialStartAt) : ''}
|
||||
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>
|
||||
<Input
|
||||
id="endAt"
|
||||
name="endAt"
|
||||
type="datetime-local"
|
||||
defaultValue={initialEndAt ? formatIsoAsBahiaDateTimeInput(initialEndAt) : ''}
|
||||
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 className="flex flex-col gap-4">
|
||||
<FieldLabel>
|
||||
<Field orientation="horizontal" className="min-h-11 rounded-lg border p-3">
|
||||
<Checkbox
|
||||
name="allDay"
|
||||
checked={allDay}
|
||||
onCheckedChange={(next) => setAllDay(Boolean(next))}
|
||||
/>
|
||||
<span>Todo o dia</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={Boolean(errorFor('startAt'))}>
|
||||
<FieldLabel htmlFor="startAt">Início *</FieldLabel>
|
||||
{allDay ? (
|
||||
<Input
|
||||
id="startAt"
|
||||
name="startAt"
|
||||
type="date"
|
||||
value={startDateOnly}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
required
|
||||
className="min-h-11"
|
||||
aria-invalid={Boolean(errorFor('startAt'))}
|
||||
aria-describedby={errorFor('startAt') ? 'startAt-error' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id="startAt"
|
||||
name="startAt"
|
||||
type="datetime-local"
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { ResponsibleMultiSelect } from '@/components/campaign/shared/Responsible
|
|||
import { StrictCombobox } from '@/components/campaign/shared/StrictCombobox'
|
||||
import { Alert, AlertDescription } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
|
|
@ -24,6 +25,7 @@ import { Field, FieldError, FieldLabel } from '@/components/ui/field'
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { allDayEndInstant, allDayRangeValid, allDayStartInstant } from '@/lib/activityAllDay'
|
||||
import {
|
||||
formatBahiaDateTimeLabel,
|
||||
formatIsoAsBahiaDateTimeInput,
|
||||
|
|
@ -112,6 +114,7 @@ const ActivityInlineCreateForm = ({
|
|||
const [title, setTitle] = useState('')
|
||||
const [start, setStart] = useState(formatIsoAsBahiaDateTimeInput(startAt))
|
||||
const [end, setEnd] = useState(formatIsoAsBahiaDateTimeInput(endAt))
|
||||
const [allDay, setAllDay] = useState(false)
|
||||
const [locality, setLocality] = useState('')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const preselectedMunicipalityId =
|
||||
|
|
@ -126,12 +129,19 @@ const ActivityInlineCreateForm = ({
|
|||
|
||||
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
|
||||
// tags), not the slot draft, so "Mais detalhes" carries everything the user
|
||||
// actually filled.
|
||||
const moreDetailsHref = buildActivityCreateHref(agendaState, {
|
||||
startAt: parseBahiaDateTimeInput(start) ?? undefined,
|
||||
endAt: end ? (parseBahiaDateTimeInput(end) ?? undefined) : undefined,
|
||||
...(allDay
|
||||
? { allDay: true, startAt: allDayStartDate, endAt: allDayEndDate }
|
||||
: {
|
||||
startAt: parseBahiaDateTimeInput(start) ?? undefined,
|
||||
endAt: end ? (parseBahiaDateTimeInput(end) ?? undefined) : undefined,
|
||||
}),
|
||||
municipalityId: municipalityValue ? Number(municipalityValue) : undefined,
|
||||
title: title.trim() || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
|
|
@ -152,9 +162,17 @@ const ActivityInlineCreateForm = ({
|
|||
nextFieldErrors.title = ['Informe um título com ao menos 2 caracteres.']
|
||||
}
|
||||
if (!municipalityId) nextFieldErrors.municipality = ['Informe o município.']
|
||||
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 (allDay) {
|
||||
if (!startIso) nextFieldErrors.startAt = ['Informe a data de início do compromisso.']
|
||||
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) {
|
||||
setFieldErrors(nextFieldErrors)
|
||||
|
|
@ -169,8 +187,16 @@ const ActivityInlineCreateForm = ({
|
|||
const result = await createActivityInline({
|
||||
title: trimmedTitle,
|
||||
municipality: municipalityId,
|
||||
startAt: startIso as string,
|
||||
...(endIso ? { endAt: endIso } : {}),
|
||||
...(allDay
|
||||
? {
|
||||
allDay: true,
|
||||
startAt: allDayStartInstant(allDayStartDate),
|
||||
...(end ? { endAt: allDayEndInstant(allDayEndDate) } : {}),
|
||||
}
|
||||
: {
|
||||
startAt: startIso as string,
|
||||
...(endIso ? { endAt: endIso } : {}),
|
||||
}),
|
||||
...(locality.trim() ? { locality: locality.trim() } : {}),
|
||||
...(responsible ? { responsible } : {}),
|
||||
// C105 — the tags mirror state (every chip mutation fires `onChange`),
|
||||
|
|
@ -214,6 +240,17 @@ const ActivityInlineCreateForm = ({
|
|||
) : null}
|
||||
</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">
|
||||
<Field data-invalid={Boolean(errorFor('startAt'))}>
|
||||
<FieldLabel htmlFor="inline-startAt">Início *</FieldLabel>
|
||||
|
|
@ -223,6 +260,7 @@ const ActivityInlineCreateForm = ({
|
|||
onValueChange={setStart}
|
||||
invalid={Boolean(errorFor('startAt'))}
|
||||
errorId={errorFor('startAt') ? 'inline-startAt-error' : undefined}
|
||||
timeVisible={!allDay}
|
||||
/>
|
||||
{errorFor('startAt') ? (
|
||||
<FieldError id="inline-startAt-error">{errorFor('startAt')}</FieldError>
|
||||
|
|
@ -236,6 +274,7 @@ const ActivityInlineCreateForm = ({
|
|||
onValueChange={setEnd}
|
||||
invalid={Boolean(errorFor('endAt'))}
|
||||
errorId={errorFor('endAt') ? 'inline-endAt-error' : undefined}
|
||||
timeVisible={!allDay}
|
||||
/>
|
||||
{errorFor('endAt') ? (
|
||||
<FieldError id="inline-endAt-error">{errorFor('endAt')}</FieldError>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { MunicipalityBaselineCard } from '@/components/campaign/municipality/Mun
|
|||
import { MunicipalityStrategyCard } from '@/components/campaign/municipality/MunicipalityStrategyCard'
|
||||
import { MunicipalityUpdateFeed } from '@/components/campaign/municipality/MunicipalityUpdateFeed'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { formatAllDayRangeLabel } from '@/lib/activityAllDay'
|
||||
import type { MunicipalityAgeBandKey } from '@/lib/bahiaMunicipalityDemographics'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
|
|
@ -86,7 +87,11 @@ const ActivityListItem = ({ activity }: { activity: ActivityListViewModel }) =>
|
|||
{activity.deputyPresent ? <Badge>Deputado presente</Badge> : null}
|
||||
</div>
|
||||
<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}` : ''}
|
||||
</p>
|
||||
</li>
|
||||
|
|
|
|||
66
src/lib/activityAllDay.ts
Normal file
66
src/lib/activityAllDay.ts
Normal 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}`
|
||||
}
|
||||
|
|
@ -191,3 +191,14 @@ export const formatBahiaCivilDateTimeLabel = (civil: string): string => {
|
|||
const [, year, month, day, hour, minute] = match
|
||||
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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { allDayRangeValid } from '@/lib/activityAllDay'
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import { campaignDemandCreateSchema } from '@/lib/schemas/campaignDemandInput'
|
||||
import {
|
||||
|
|
@ -125,6 +126,7 @@ const activityFieldsSchema = z.object({
|
|||
status: z.enum(activityStatuses).optional(),
|
||||
description: trimmedOptionalText(4000),
|
||||
deputyPresent: z.boolean().optional(),
|
||||
allDay: z.boolean().optional(),
|
||||
startAt: z.string().datetime().optional().nullable(),
|
||||
endAt: z.string().datetime().optional().nullable(),
|
||||
municipality: positiveRelationshipId,
|
||||
|
|
@ -137,6 +139,7 @@ const activityFieldsSchema = z.object({
|
|||
const validateSchedule = (
|
||||
data: {
|
||||
status?: ActivityStatus | null
|
||||
allDay?: boolean | null
|
||||
startAt?: string | null
|
||||
endAt?: string | null
|
||||
},
|
||||
|
|
@ -146,7 +149,9 @@ const validateSchedule = (
|
|||
if (requireStart && !data.startAt) {
|
||||
context.addIssue({
|
||||
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'],
|
||||
})
|
||||
}
|
||||
|
|
@ -154,7 +159,18 @@ const validateSchedule = (
|
|||
if (data.startAt && data.endAt) {
|
||||
const start = new Date(data.startAt)
|
||||
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({
|
||||
code: 'custom',
|
||||
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
|
||||
.object({
|
||||
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,
|
||||
endAt: isoInstantSchema.nullable(),
|
||||
})
|
||||
.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)
|
||||
})
|
||||
.transform((data) => ({
|
||||
|
|
|
|||
13193
src/migrations/20260810_010844_add_activity_all_day.json
Normal file
13193
src/migrations/20260810_010844_add_activity_all_day.json
Normal file
File diff suppressed because it is too large
Load diff
13
src/migrations/20260810_010844_add_activity_all_day.ts
Normal file
13
src/migrations/20260810_010844_add_activity_all_day.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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_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_20260810_010844_add_activity_all_day from './20260810_010844_add_activity_all_day';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -286,6 +287,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260809_204728_add_campaign_user_contact_and_leadership_advisors.up,
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -744,6 +744,10 @@ export interface Activity {
|
|||
* Marque quando o deputado Jorge Solla estiver presente na atividade.
|
||||
*/
|
||||
deputyPresent?: boolean | null;
|
||||
/**
|
||||
* Compromisso que ocupa um ou mais dias inteiros, sem horário.
|
||||
*/
|
||||
allDay?: boolean | null;
|
||||
startAt?: string | null;
|
||||
endAt?: string | null;
|
||||
municipality: number | Municipality;
|
||||
|
|
@ -1799,6 +1803,7 @@ export interface ActivitySelect<T extends boolean = true> {
|
|||
status?: T;
|
||||
description?: T;
|
||||
deputyPresent?: T;
|
||||
allDay?: T;
|
||||
startAt?: T;
|
||||
endAt?: T;
|
||||
municipality?: T;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'server-only'
|
||||
|
||||
import { allDayStartInstant, isCivilDate } from '@/lib/activityAllDay'
|
||||
import { parseBahiaDateTimeInput } from '@/lib/campaignTime'
|
||||
import {
|
||||
boundedJsonFormValue,
|
||||
|
|
@ -34,9 +35,19 @@ type ParsedActivityTask = {
|
|||
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)
|
||||
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)
|
||||
if (!iso) throw new FormDataBoundaryError(field, 'Data e horários inválidos.')
|
||||
return iso
|
||||
|
|
@ -151,14 +162,16 @@ const parseResponsiblesFormData = (formData: FormData): ParsedResponsible[] => {
|
|||
}
|
||||
|
||||
const parseSharedActivityFormData = (formData: FormData) => {
|
||||
const allDay = checkboxFormValue(formData, 'allDay')
|
||||
return {
|
||||
title: optionalFormText(formData, 'title') ?? '',
|
||||
tags: parseTagsFormData(formData),
|
||||
status: optionalFormText(formData, 'status') as (typeof activityStatuses)[number] | undefined,
|
||||
description: optionalFormText(formData, 'description'),
|
||||
deputyPresent: checkboxFormValue(formData, 'deputyPresent'),
|
||||
startAt: parseDateTimeFormField(formData, 'startAt'),
|
||||
endAt: parseDateTimeFormField(formData, 'endAt'),
|
||||
allDay,
|
||||
startAt: parseScheduleFormField(formData, 'startAt', allDay),
|
||||
endAt: parseScheduleFormField(formData, 'endAt', allDay),
|
||||
municipality: requiredRelationshipFormValue(formData, 'municipality'),
|
||||
locality: optionalFormText(formData, 'locality'),
|
||||
organizations: repeatedRelationshipFormValues(formData, 'organizations'),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Where } from 'payload'
|
||||
|
||||
import { allDayStartInstant } from '@/lib/activityAllDay'
|
||||
import { CAMPAIGN_AGENDA_HOME } from '@/lib/campaignPaths'
|
||||
import {
|
||||
formatBahiaCivilDate,
|
||||
|
|
@ -282,6 +283,8 @@ export type ActivityCreatePrefill = {
|
|||
municipalityId?: number
|
||||
title?: string
|
||||
tags?: string[]
|
||||
/** C104 — the sheet opened the toggle; startAt/endAt are civil dates. */
|
||||
allDay?: boolean
|
||||
}
|
||||
|
||||
export const buildActivityCreateHref = (
|
||||
|
|
@ -289,8 +292,14 @@ export const buildActivityCreateHref = (
|
|||
prefill: Partial<ActivityCreatePrefill> = {},
|
||||
): string => {
|
||||
const params = new URLSearchParams()
|
||||
if (prefill.startAt) params.set('startAt', prefill.startAt)
|
||||
if (prefill.endAt) params.set('endAt', prefill.endAt)
|
||||
if (prefill.allDay) {
|
||||
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
|
||||
if (municipalityId) params.set('municipality', String(municipalityId))
|
||||
if (prefill.title) params.set('title', prefill.title)
|
||||
|
|
@ -385,9 +394,17 @@ export const parseActivityCreatePrefill = (
|
|||
params: RawSearchParams,
|
||||
accessibleMunicipalityIDs: ReadonlySet<number>,
|
||||
): ActivityCreatePrefill => {
|
||||
const startAt = normalizedIsoInstant(firstValue(params.startAt))
|
||||
const rawEndAt = normalizedIsoInstant(firstValue(params.endAt))
|
||||
const endAt = startAt && rawEndAt && new Date(rawEndAt) > new Date(startAt) ? rawEndAt : undefined
|
||||
const rawAllDay = firstValue(params.allDay)
|
||||
const allDay = rawAllDay === '1'
|
||||
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 municipalityId =
|
||||
rawMunicipality && accessibleMunicipalityIDs.has(rawMunicipality) ? rawMunicipality : undefined
|
||||
|
|
@ -397,6 +414,7 @@ export const parseActivityCreatePrefill = (
|
|||
const tags = parseActivityPrefillTags(params.tags)
|
||||
|
||||
return {
|
||||
...(allDay ? { allDay } : {}),
|
||||
...(startAt ? { startAt } : {}),
|
||||
...(endAt ? { endAt } : {}),
|
||||
...(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 activityListParamNameSet = new Set<string>(activityListParamNames)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { formatAllDayRangeLabel } from '@/lib/activityAllDay'
|
||||
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
||||
import { isPopulatedRelationship, populatedContactName, relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
|
|
@ -85,14 +86,21 @@ const mapActivityResponsibles = (
|
|||
}))
|
||||
}
|
||||
|
||||
export const formatActivityWhenLabel = (startAt: string | null | undefined): string =>
|
||||
startAt ? formatBahiaDateTimeLabel(startAt) : 'Data a definir'
|
||||
export const formatActivityWhenLabel = (
|
||||
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 = (
|
||||
municipalityName: string | null,
|
||||
startAt: string | null | undefined,
|
||||
options: { allDay?: boolean | null; endAt?: string | null } = {},
|
||||
): string => {
|
||||
const whenLabel = formatActivityWhenLabel(startAt)
|
||||
const whenLabel = formatActivityWhenLabel(startAt, options)
|
||||
if (!municipalityName) return whenLabel
|
||||
return `${municipalityName} · ${whenLabel}`
|
||||
}
|
||||
|
|
@ -112,6 +120,7 @@ export const activityAgendaSelect = {
|
|||
tags: true,
|
||||
status: true,
|
||||
deputyPresent: true,
|
||||
allDay: true,
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
municipality: true,
|
||||
|
|
@ -132,6 +141,7 @@ export type ActivityAgendaEvent = {
|
|||
tags: string[]
|
||||
status: Activity['status']
|
||||
deputyPresent: boolean
|
||||
allDay: boolean
|
||||
startAt: string
|
||||
endAt: string | null
|
||||
municipality: ActivityMunicipalitySummary | null
|
||||
|
|
@ -155,6 +165,7 @@ export const toActivityAgendaEvent = (
|
|||
tags: activity.tags ?? [],
|
||||
status: activity.status,
|
||||
deputyPresent,
|
||||
allDay: Boolean(activity.allDay),
|
||||
startAt: activity.startAt,
|
||||
endAt: activity.endAt ?? null,
|
||||
municipality: activityMunicipalitySummary(activity.municipality),
|
||||
|
|
@ -170,6 +181,7 @@ export type ActivityListViewModel = {
|
|||
tags: string[]
|
||||
status: Activity['status']
|
||||
deputyPresent: boolean
|
||||
allDay: boolean
|
||||
startAt: string | null
|
||||
endAt: string | null
|
||||
municipalityName: string | null
|
||||
|
|
@ -193,6 +205,7 @@ export const toActivityListViewModel = (activity: Activity): ActivityListViewMod
|
|||
tags: activity.tags ?? [],
|
||||
status: activity.status,
|
||||
deputyPresent: Boolean(activity.deputyPresent),
|
||||
allDay: Boolean(activity.allDay),
|
||||
startAt: activity.startAt ?? null,
|
||||
endAt: activity.endAt ?? null,
|
||||
municipalityName,
|
||||
|
|
@ -213,6 +226,7 @@ export const activityFormSelect = {
|
|||
status: true,
|
||||
description: true,
|
||||
deputyPresent: true,
|
||||
allDay: true,
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
municipality: true,
|
||||
|
|
@ -238,6 +252,7 @@ export type ActivityFormViewModel = {
|
|||
status: Activity['status']
|
||||
description: string | null
|
||||
deputyPresent: boolean
|
||||
allDay: boolean
|
||||
startAt: string | null
|
||||
endAt: string | null
|
||||
municipalityId: number | null
|
||||
|
|
@ -258,6 +273,7 @@ export const toActivityFormViewModel = (activity: Activity): ActivityFormViewMod
|
|||
status: activity.status,
|
||||
description: activity.description ?? null,
|
||||
deputyPresent: Boolean(activity.deputyPresent),
|
||||
allDay: Boolean(activity.allDay),
|
||||
startAt: activity.startAt ?? null,
|
||||
endAt: activity.endAt ?? null,
|
||||
municipalityId: relationshipId(activity.municipality),
|
||||
|
|
@ -286,6 +302,7 @@ const activityDetailContextSelect = {
|
|||
status: true,
|
||||
description: true,
|
||||
deputyPresent: true,
|
||||
allDay: true,
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
municipality: true,
|
||||
|
|
@ -351,6 +368,7 @@ export type ActivityDetailViewModel = {
|
|||
status: Activity['status']
|
||||
description: string | null
|
||||
deputyPresent: boolean
|
||||
allDay: boolean
|
||||
startAt: string | null
|
||||
endAt: string | null
|
||||
municipality: ActivityMunicipalitySummary | null
|
||||
|
|
@ -418,6 +436,7 @@ export const toActivityDetailViewModel = (
|
|||
status: activity.status,
|
||||
description: activity.description ?? null,
|
||||
deputyPresent: Boolean(activity.deputyPresent),
|
||||
allDay: Boolean(activity.allDay),
|
||||
startAt: activity.startAt ?? null,
|
||||
endAt: activity.endAt ?? null,
|
||||
municipality,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'server-only'
|
||||
|
||||
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
|
||||
import type { Activity, CalendarFeed } from '@/payload-types'
|
||||
import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared'
|
||||
import type { Payload, Where } from 'payload'
|
||||
|
|
@ -49,16 +50,22 @@ export const generateICalFeed = (
|
|||
const dtStart = formatICalDate(activity.startAt)
|
||||
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
|
||||
|
||||
lines.push('BEGIN:VEVENT')
|
||||
lines.push(`UID:${activity.slug}@teqo.jorgesolla.com.br`)
|
||||
lines.push(`DTSTART:${dtStart}`)
|
||||
if (activity.endAt) {
|
||||
lines.push(`DTEND:${dtEnd}`)
|
||||
} else {
|
||||
lines.push(`DTEND:${dtStart}`)
|
||||
}
|
||||
lines.push(dtStartLine)
|
||||
lines.push(dtEndLine)
|
||||
lines.push(`SUMMARY:${escapeICalText(summary)}`)
|
||||
lines.push(`DESCRIPTION:${buildActivityDescription(activity, municipalityName)}`)
|
||||
lines.push(`DTSTAMP:${formatICalDate(activity.updatedAt || activity.createdAt)}`)
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ export const searchHomeActivities = async (
|
|||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
allDay: true,
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
municipality: true,
|
||||
},
|
||||
user,
|
||||
|
|
@ -76,7 +78,10 @@ export const searchHomeActivities = async (
|
|||
id: doc.id,
|
||||
slug: doc.slug,
|
||||
title: doc.title,
|
||||
secondary: formatActivityHomeSearchSecondary(municipalityName, doc.startAt),
|
||||
secondary: formatActivityHomeSearchSecondary(municipalityName, doc.startAt, {
|
||||
allDay: doc.allDay,
|
||||
endAt: doc.endAt,
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,25 +181,19 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
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)
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
// FullCalendar renders the time axis lazily and re-lays out on mount: the
|
||||
// grid first paints empty and re-renders (replacing its gridcells) when the
|
||||
// events land. Wait for the load to finish before interacting, or the
|
||||
// locator's element is replaced mid-action ("Element is not attached").
|
||||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
await expect(slotLocator).toBeVisible()
|
||||
await expect(dayCell).toBeVisible()
|
||||
await dayCell.scrollIntoViewIfNeeded()
|
||||
const slotBox = await slotLocator.boundingBox()
|
||||
const dayBox = await dayCell.boundingBox()
|
||||
if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.')
|
||||
await dayCell.click({
|
||||
position: {
|
||||
x: dayBox.width / 2,
|
||||
y: slotBox.y - dayBox.y + slotBox.height / 2,
|
||||
},
|
||||
})
|
||||
// C104 — with the all-day lane enabled, the v7 classic theme paints the
|
||||
// all-day day fills over the time grid; real clicks still land on the slot
|
||||
// (FullCalendar resolves by data-time), but Playwright's actionability
|
||||
// check refuses them, so the click is forced on the slot lane itself.
|
||||
await slotLocator.scrollIntoViewIfNeeded()
|
||||
await slotLocator.click({ force: true })
|
||||
|
||||
// The click no longer navigates: the inline overlay opens at the slot.
|
||||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||||
|
|
@ -251,6 +245,66 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
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}`)
|
||||
|
||||
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()
|
||||
// C104 — with the all-day lane enabled, the v7 classic theme paints the
|
||||
// all-day day fills over the time grid; real clicks still land on the slot
|
||||
// (FullCalendar resolves by data-time), but Playwright's actionability
|
||||
// check refuses them, so the click is forced on the slot lane itself.
|
||||
await slotLocator.scrollIntoViewIfNeeded()
|
||||
await slotLocator.click({ force: true })
|
||||
|
||||
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 ({
|
||||
campaign,
|
||||
page,
|
||||
|
|
@ -263,25 +317,19 @@ test.describe('Agenda — calendário operacional', () => {
|
|||
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)
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
// FullCalendar renders the time axis lazily and re-lays out on mount: the
|
||||
// grid first paints empty and re-renders (replacing its gridcells) when the
|
||||
// events land. Wait for the load to finish before interacting, or the
|
||||
// locator's element is replaced mid-action ("Element is not attached").
|
||||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||||
await expect(slotLocator).toBeVisible()
|
||||
await expect(dayCell).toBeVisible()
|
||||
await dayCell.scrollIntoViewIfNeeded()
|
||||
const slotBox = await slotLocator.boundingBox()
|
||||
const dayBox = await dayCell.boundingBox()
|
||||
if (!slotBox || !dayBox) throw new Error('A grade semanal não expôs o slot esperado.')
|
||||
await dayCell.click({
|
||||
position: {
|
||||
x: dayBox.width / 2,
|
||||
y: slotBox.y - dayBox.y + slotBox.height / 2,
|
||||
},
|
||||
})
|
||||
// C104 — with the all-day lane enabled, the v7 classic theme paints the
|
||||
// all-day day fills over the time grid; real clicks still land on the slot
|
||||
// (FullCalendar resolves by data-time), but Playwright's actionability
|
||||
// check refuses them, so the click is forced on the slot lane itself.
|
||||
await slotLocator.scrollIntoViewIfNeeded()
|
||||
await slotLocator.click({ force: true })
|
||||
|
||||
await expect(page.getByLabel('Título *')).toBeVisible()
|
||||
await page.getByLabel('Título *').fill(title)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
loadActivityAgendaEventsRecord,
|
||||
rescheduleActivityRecord,
|
||||
} from '@/app/(campaign)/campanha/actions/activity'
|
||||
import { allDayEndInstant, allDayStartInstant } from '@/lib/activityAllDay'
|
||||
import { relationshipId } from '@/lib/relationship'
|
||||
import {
|
||||
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.')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const advisor = await fixtures.createCampaignUser('advisor')
|
||||
|
|
@ -554,6 +682,7 @@ describe('activity domain', () => {
|
|||
const nextStart = new Date(Date.now() + 172_800_000).toISOString()
|
||||
|
||||
const updated = await rescheduleActivityRecord(payload, advisor, {
|
||||
allDay: false,
|
||||
id: activity.id,
|
||||
startAt: nextStart,
|
||||
endAt: null,
|
||||
|
|
@ -572,6 +701,7 @@ describe('activity domain', () => {
|
|||
|
||||
await expect(
|
||||
rescheduleActivityRecord(payload, advisor, {
|
||||
allDay: false,
|
||||
id: activity.id,
|
||||
startAt: new Date(Date.now() + 259_200_000).toISOString(),
|
||||
endAt: null,
|
||||
|
|
@ -609,6 +739,7 @@ describe('activity domain', () => {
|
|||
] as const) {
|
||||
const nextStart = new Date(Date.now() + offset).toISOString()
|
||||
const updated = await rescheduleActivityRecord(payload, actor, {
|
||||
allDay: false,
|
||||
id: activity.id,
|
||||
startAt: nextStart,
|
||||
endAt: null,
|
||||
|
|
|
|||
|
|
@ -59,11 +59,13 @@ describe('activity reschedule input', () => {
|
|||
expect(
|
||||
activityRescheduleSchema.parse({
|
||||
id: 7,
|
||||
allDay: false,
|
||||
startAt: '2026-08-07T10:00:00-03:00',
|
||||
endAt: null,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 7,
|
||||
allDay: false,
|
||||
startAt: '2026-08-07T13:00:00.000Z',
|
||||
endAt: null,
|
||||
})
|
||||
|
|
@ -73,11 +75,50 @@ describe('activity reschedule input', () => {
|
|||
expect(
|
||||
activityRescheduleSchema.safeParse({
|
||||
id: 7,
|
||||
allDay: false,
|
||||
startAt: '2026-08-07T13:00:00.000Z',
|
||||
endAt: '2026-08-07T12:59:00.000Z',
|
||||
}).success,
|
||||
).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', () => {
|
||||
|
|
@ -120,6 +161,7 @@ describe('activity agenda event view model', () => {
|
|||
tags: ['Caminhada'],
|
||||
status: 'confirmado',
|
||||
deputyPresent: false,
|
||||
allDay: false,
|
||||
startAt: '2026-08-07T13:00:00.000Z',
|
||||
endAt: null,
|
||||
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', () => {
|
||||
expect(
|
||||
toActivityAgendaEvent(
|
||||
|
|
|
|||
|
|
@ -140,6 +140,18 @@ describe('ActivityAgenda schedule failures', () => {
|
|||
// toolbar and the calendar body are what these tests exercise).
|
||||
beforeEach(() => {
|
||||
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(() => {
|
||||
|
|
|
|||
92
tests/unit/activityAllDay.unit.spec.ts
Normal file
92
tests/unit/activityAllDay.unit.spec.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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.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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -441,6 +441,78 @@ describe('activity create prefill', () => {
|
|||
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)', () => {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,45 @@ describe('generateICalFeed', () => {
|
|||
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', () => {
|
||||
const activities = [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue