14 KiB
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
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)
allDayboolean + instantes com fim inclusivo (startAt = 1º dia 00:00, endAt = último dia 00:00 Bahia) | B)allDayboolean + 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 emlib/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 delib/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
persistScheduleda 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 regrastart <= endpara 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 oallDayatual 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 deallDay+ 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 flipsallDayem drops entre lane e grade. Persistir a conversão é feature nova (C103-adjacent, fora do aceite) e muda o reschedule para escreverallDay; 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.EventResizeDoneInfonão temoldEvente resize não troca allDay.
D5 — Controle date-only.
- Opções: A) prop
timeVisible?: booleannoActivityDateTimeField(default true) — popover renderiza só o Calendar, trigger formata só a data | B)<Input type="date">no sheet | C) componente novoActivityDateField. - 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/endAtcontrolados viauseState(inicializados de activity/prefill) | B) uncontrolled +keypara 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/endAtcomo datas civis (2026-08-10) nos params | B) instantes ISO +allDay=1. - Recomendação: A — data civil é o vocabulário do modo;
parseActivityCreatePrefillramifica emallDay=1(regexYYYY-MM-DD→ instantes via lib); oActivityCreatePrefillganhaallDay?: booleane 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;ActivityOverviewTabInício/Término → branch allDay;MunicipalityDossier(linha 89) → branch allDay;searchHomeActivitiesselect ganhaallDay/endAte repassa;- página da organização já imprime
dateStyle: 'short'(só data) — sem mudança; calendarFeed.ts→DTSTART;VALUE=DATE/DTEND;VALUE=DATEcom fim exclusivo.
Componentes / mudanças
- Migration:
add_activity_all_day— colunaall_day boolean NOT NULL DEFAULT false(Payload checkbox +hasDefaultValue? sem default explícito o schema exige o campo; defaultfalseno config, migration comdefault: false). Nunca editar migração já commitada; seguir o skillpayload-migrations. src/lib/activityAllDay.ts(novo, puro): conversões de D2.src/lib/schemas/activity.ts:activityFieldsSchemaganhaallDay: z.boolean().optional();validateScheduleramifica (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);activityRescheduleSchemaganhaallDay: z.boolean()(endAt obrigatório estart <= endquando allDay).src/collections/Activity.ts: campoallDay(label "Todo o dia", default false); hookvalidateActivitySchedulecom a mesma branch;activityStaffFieldSnapshotincluiallDay(liderança não pode flipar).src/utilities/activityFormData.ts:parseDateTimeFormFieldramifica no checkboxallDay— datas civis → instantes via lib/activityAllDay.src/components/campaign/activity/ActivityDateTimeField.tsx: proptimeVisible(default true) — trigger comformatBahiaCivilDateLabel(novo helper puro emcampaignTime.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 comallDay+ instantes da lib; "Mais detalhes" comallDay=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 deformatIsoAsBahiaDateInput= date part).src/components/campaign/activity/ActivityAgenda.tsx:toEventInputbranch allDay (start date-only, end exclusivo,allDay: true);allDaySlotligado (removerallDaySlot={false});persistScheduleconvertestartStr/endStrexclusivos → storage e enviaallDay; guard D4 nohandleScheduleChange.src/utilities/activityUi.ts:ActivityCreatePrefill.allDay;parseActivityCreatePrefillramifica;buildActivityCreateHrefemiteallDay=1+ datas.src/utilities/activityViewModels.ts:allDayemActivityAgendaEvent,ActivityListViewModel,ActivityFormViewModel,ActivityDetailViewModel+ selects (activityAgendaSelect,activityListSelect,activityFormSelect,activityDetailContextSelect);formatActivityWhenLabel/formatActivityHomeSearchSecondarycom 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
- Schema + server — migration
add_activity_all_day+pnpm migratelocal +generate:types;lib/activityAllDay.ts+ unit tests; schema (activity.ts) + hooks da collection;activityFormData.ts; VMs + selects; iCal; tests unit/int de domínio. - UI —
ActivityDateTimeField.timeVisible; sheet (toggle + validação + href); agenda (toEventInput,allDaySlot,persistSchedule, guard); form completo; surfaces de rótulo; tests de componente/unit. - Gates —
pnpm gate:fastna iteração;pnpm pushno 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.tscom 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: falsemantido; 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