From ea700e3113aaaa271b6787171839964d1dd2c0a8 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 22:40:06 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(C104):=20compromisso=20'Todo=20o=20dia?= =?UTF-8?q?'=20na=20cria=C3=A7=C3=A3o=20r=C3=A1pida=20da=20agenda=20(s?= =?UTF-8?q?=C3=B3=20datas,=20multi-dia)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG-AGENTS.md | 6 +- docs/plans/agenda-toggle-todo-o-dia-impl.md | 127 + .../atividades/[slug]/ActivityOverviewTab.tsx | 17 +- src/collections/Activity.ts | 34 +- .../campaign/activity/ActivityAgenda.tsx | 46 +- .../campaign/activity/ActivityCard.tsx | 7 +- .../activity/ActivityDateTimeField.tsx | 77 +- .../campaign/activity/ActivityForm.tsx | 121 +- .../activity/ActivityInlineCreate.tsx | 53 +- .../municipality/MunicipalityDossier.tsx | 7 +- src/lib/activityAllDay.ts | 66 + src/lib/campaignTime.ts | 11 + src/lib/schemas/activity.ts | 42 +- .../20260810_010844_add_activity_all_day.json | 13193 ++++++++++++++++ .../20260810_010844_add_activity_all_day.ts | 13 + src/migrations/index.ts | 8 +- src/payload-types.ts | 5 + src/utilities/activityFormData.ts | 19 +- src/utilities/activityUi.ts | 38 +- src/utilities/activityViewModels.ts | 25 +- src/utilities/calendarFeed.ts | 19 +- .../homeSearch/searchHomeActivities.ts | 7 +- tests/e2e/campaignActivity.e2e.spec.ts | 66 + tests/int/campaignActivity.int.spec.ts | 131 + tests/unit/activityAgenda.unit.spec.ts | 61 + .../activityAgendaInteractions.unit.spec.tsx | 12 + tests/unit/activityAllDay.unit.spec.ts | 92 + tests/unit/activityInlineCreate.unit.spec.tsx | 89 + tests/unit/activityUi.unit.spec.ts | 72 + tests/unit/calendarFeed.unit.spec.ts | 39 + 30 files changed, 14390 insertions(+), 113 deletions(-) create mode 100644 docs/plans/agenda-toggle-todo-o-dia-impl.md create mode 100644 src/lib/activityAllDay.ts create mode 100644 src/migrations/20260810_010844_add_activity_all_day.json create mode 100644 src/migrations/20260810_010844_add_activity_all_day.ts create mode 100644 tests/unit/activityAllDay.unit.spec.ts diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 33bb8b4a..72d6b37e 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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 `` 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 ``). 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). diff --git a/docs/plans/agenda-toggle-todo-o-dia-impl.md b/docs/plans/agenda-toggle-todo-o-dia-impl.md new file mode 100644 index 00000000..5c920d88 --- /dev/null +++ b/docs/plans/agenda-toggle-todo-o-dia-impl.md @@ -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
sheet + form] --> B[allDay: boolean
nova coluna] + B --> C[Convenção de armazenamento
startAt = 1º dia 00:00 Bahia
endAt = último dia 00:00 Bahia
fim inclusivo] + C --> D[lib/activityAllDay.ts
puro: datas civis ↔ instantes
+ fim exclusivo p/ FullCalendar/iCal] + D --> E[Agenda: toEventInput allDay
+ allDaySlot ligado + drop guard] + D --> F[iCal: DTSTART/DTEND VALUE=DATE] + D --> G[Rótulos: card, overview, dossiê,
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) `` 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`; `` 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); `` 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:` / `DTEND;VALUE=DATE:`. +- **`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 diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx index 0ba54d8a..05371368 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/ActivityOverviewTab.tsx @@ -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 = ({
Início
-
{view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'A definir'}
+
+ {view.startAt + ? view.allDay + ? formatAllDayRangeLabel(view.startAt) + : formatBahiaDateTimeLabel(view.startAt) + : 'A definir'} +
Término
-
{view.endAt ? formatBahiaDateTimeLabel(view.endAt) : '—'}
+
+ {view.endAt + ? view.allDay + ? formatAllDayRangeLabel(view.endAt) + : formatBahiaDateTimeLabel(view.endAt) + : '—'} +
Tags
diff --git a/src/collections/Activity.ts b/src/collections/Activity.ts index 59460059..49fdc4a8 100644 --- a/src/collections/Activity.ts +++ b/src/collections/Activity.ts @@ -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) => ({ 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', diff --git a/src/components/campaign/activity/ActivityAgenda.tsx b/src/components/campaign/activity/ActivityAgenda.tsx index da15d350..1ba90bea 100644 --- a/src/components/campaign/activity/ActivityAgenda.tsx +++ b/src/components/campaign/activity/ActivityAgenda.tsx @@ -35,6 +35,11 @@ import type { RelationOption } from '@/components/campaign/shared/RelationMultiS import { Button } from '@/components/ui/button' import { useIsMobileMeasured } from '@/hooks/use-mobile' import { formatBahiaCivilDate } from '@/lib/campaignTime' +import { + allDayEndInstantFromExclusive, + allDayExclusiveEndDate, + allDayStartInstant, +} from '@/lib/activityAllDay' import { ACTIVITY_RESCHEDULE_FAILED_MESSAGE, activityStatusLabels, @@ -83,9 +88,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, @@ -359,11 +368,13 @@ export const ActivityAgenda = ({ const persistSchedule = useCallback( async ({ id, + allDay, startAt, endAt, revert, }: { id: string + allDay: boolean startAt: string endAt: string | null revert: () => void @@ -376,7 +387,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) { @@ -405,12 +416,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], ) @@ -459,7 +482,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} diff --git a/src/components/campaign/activity/ActivityCard.tsx b/src/components/campaign/activity/ActivityCard.tsx index f9e10c64..a0f5c22f 100644 --- a/src/components/campaign/activity/ActivityCard.tsx +++ b/src/components/campaign/activity/ActivityCard.tsx @@ -30,7 +30,12 @@ export const ActivityCard = ({ activity }: { activity: ActivityListViewModel })
{activity.locationLabel ? (
diff --git a/src/components/campaign/activity/ActivityDateTimeField.tsx b/src/components/campaign/activity/ActivityDateTimeField.tsx index cec95963..e5b66892 100644 --- a/src/components/campaign/activity/ActivityDateTimeField.tsx +++ b/src/components/campaign/activity/ActivityDateTimeField.tsx @@ -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', )} > - {formatBahiaCivilDateTimeLabel(civil)} + + {timeVisible ? formatBahiaCivilDateTimeLabel(civil) : formatBahiaCivilDateLabel(civil)} +