Files
agenciapsilmno/Obsidian/Brain/wiki/recorrencia-agenda.md
T
Leonardo af8aee9188 wiki: documenta recorrencia da agenda + log da sessao 2026-05-11
Nova pagina [[recorrencia-agenda]] cobrindo: modelo "1 real + N-1 virtual"
via useRecurrence, quem expande virtuais (composables corrigidos em 39cf017),
pattern de materializacao ao mudar status (4 caminhos), view listAll de
2 anos no MelissaAgenda, visual de evento inativo, e query SQL pra detectar
rows orfas. index.md ganhou link sob Concepts.

Log entry da sessao 2026-05-11 10:50 cobrindo os 6 commits previos
(8b0e633..39cf017): time picker, services nome unico, paciente arquivado/
inativo, AgendaEventDialog overhaul, view lista Melissa, expansao+
materializacao de recorrencia.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:56:49 -03:00

6.0 KiB

Recorrência na Agenda

Como o sistema modela e exibe sessões recorrentes. Decisões arquiteturais importantes que não são óbvias só lendo o código.

Modelo de dados — "1 real + N-1 virtual"

Quando o user cria "4 sessões semanais", o sistema escreve só 2 rows no banco:

  1. 1 row em agenda_eventos — a primeira ocorrência, materializada. Tem recurrence_id apontando pra regra abaixo.
  2. 1 row em recurrence_rules — a "semente": start_date, type='weekly', interval=1, max_occurrences=4 (ou open_ended=true).

As sessões 2, 3, 4 NÃO existem no banco. São geradas em runtime por useRecurrence.loadAndExpand — chamado pelas páginas de agenda quando precisam exibir um range. ID virtual: rec::ruleId::originalDateISO.

Trade-off da escolha:

  • Cria recorrência infinita (open-ended) sem inflar o DB
  • Mudança na regra (preço, modalidade, etc) reflete em todas as ocorrências automaticamente
  • Toda exibição precisa chamar loadAndExpand no range visível
  • Edição de uma ocorrência específica exige materializar primeiro (criar a row real com recurrence_id + recurrence_date)

Quem expande virtuais (e quem não)

Expande:

  • AgendaTerapeutaPage (Rail) — loadAndExpand no range mensal + na busca
  • AgendaClinicaPage (Clínica) — mesma coisa, com tenant
  • useMelissaAgenda._reloadRange (Melissa FullCalendar) — expande no range visível
  • usePatientSessions.load — range -6mo a +12mo, filtra por patient_id
  • useMelissaEventos._fetchRange — expande no range pedido. Cobre widget "Hoje", mini-cal, fallback
  • useMelissaTodasSessoesPaciente.fetch — range -6mo a +12mo, filtra por patient_id

Antes de 2026-05-11 os 3 últimos NÃO expandiam — uma série semanal de 4 aparecia como 1. Comentário no código admitia "adicionar quando promover Melissa pra produção". Bug resolvido nesta data.

Cap do range — MAX_RANGE_DAYS = 730

useRecurrence.expandRules loga warning quando o range visível ultrapassa 730 dias (2 anos). É só warning, não bloqueia. A listAll view custom do MelissaAgenda usa exatamente duration: { years: 2 } pra bater no cap.

Materialização — "ao mudar status numa virtual"

UPDATE direto numa row com id = "rec::..." quebra com invalid input syntax for type uuid. Pra mudar status (cancelar, marcar realizada, etc) numa ocorrência virtual, é preciso:

  1. Buscar em agenda_eventos se já existe row materializada (recurrence_id + recurrence_date).
  2. Se sim, UPDATE status nela.
  3. Se não, INSERT nova row copiando campos da virtual + status novo.

Pattern central: useMelissaAgenda.onUpdateSeriesEvent(...) (e gêmeas em AgendaTerapeutaPage / AgendaClinicaPage). Aceita opcional row do chamador — quando o user clica direto no evento sem abrir o dialog antes, dialogEventRow está vazio e a função precisaria buscar a regra de outro lugar.

Caminhos que mudam status (e como chegam à materialização)

Onde Composable/Handler Comportamento virtual
MelissaEventoPanel (painel lateral do calendário) MelissaLayout.updateEventoStatus Detecta is_occurrence → delega M.onUpdateSeriesEvent({ row: ev, ... })
AgendaEventDialog SelectButton form.status (Cancelado/Remarcar) useAgendaEventActions watcher emit('updateSeriesEvent', { row, ... }) em vez de UPDATE direto
AgendaEventDialog pills da série useAgendaEventLifecycle.onPillStatusChange Já emitia desde sempre
MelissaPaciente Tab Agenda botões diretos usePatientSessions.updateStatus(ev, status) Aceita row inteira; se virtual, materializa internamente

Guard importante em onUpdateSeriesEvent: se recurrence_id resolver pra null (callerRow + dialogEventRow ambos sem ele), aborta com toast. Antes criava row órfã com patient_id: null aparecendo "Faltou sem nome" no calendário.

View listAll no MelissaAgenda

View custom (não built-in do FC) com duration: { years: 2 }. setView('lista') faz gotoDate(hoje - 1 ano) pra centrar passado + presente + futuro. Substituiu listWeek que mostrava só 7 dias.

Banner showRecurrenceHint aparece nas outras views (dia/semana/mês) quando há virtuais visíveis — botão "Ver na lista" troca pra listAll. Sem o banner, user não percebe que tem ocorrências fora do range.

Visual de evento inativo

normalizeEvent (useMelissaEventos.js + useMelissaAgenda.normalizeForMelissa) carrega paciente_status do JOIN. MelissaAgenda aplica classNames: ['ma-evt--inactive-patient'] quando 'Arquivado'|'Inativo' — CSS dá borda tracejada + opacidade 0.58 + itálico em list view. Mantém a cor do commitment pra não perder contexto.

Picker do AgendaEventDialog / V2: mostra TODOS os pacientes (Ativo > Inativo > Arquivado), nao-Ativos com Tag + disabled + tooltip. selectPaciente bloqueia non-Ativo como defesa em camadas.

Quando algo der errado

Se aparecer "sessão fantasma sem nome" no calendário, provavelmente é row órfã criada por materialização sem patient_id/recurrence_id. Query pra detectar:

SELECT id, inicio_em, status, patient_id, recurrence_id
FROM agenda_eventos
WHERE patient_id IS NULL
  AND recurrence_id IS NULL
  AND tipo = 'sessao'
  AND created_at > NOW() - INTERVAL '1 day';

Causa raiz já corrigida em 2026-05-11 (guard contra rid null em onUpdateSeriesEvent), mas o pattern de query é útil pra catch futuros.

Referências de código

  • src/features/agenda/composables/useRecurrence.jsloadAndExpand, expandRules, mergeWithStoredSessions, buildOccurrence
  • src/layout/melissa/composables/useMelissaAgenda.js:809onUpdateSeriesEvent
  • src/features/agenda/composables/useAgendaEventActions.js:65 — watcher do form.status
  • src/features/patients/composables/usePatientSessions.js:189updateStatus com materialização
  • src/layout/melissa/MelissaLayout.vue:655updateEventoStatus do MelissaEventoPanel
  • src/layout/melissa/MelissaAgenda.vue:244VIEW_MAP.lista = 'listAll'