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>
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 row em
agenda_eventos— a primeira ocorrência, materializada. Temrecurrence_idapontando pra regra abaixo. - 1 row em
recurrence_rules— a "semente":start_date,type='weekly',interval=1,max_occurrences=4(ouopen_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
loadAndExpandno 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) —loadAndExpandno range mensal + na buscaAgendaClinicaPage(Clínica) — mesma coisa, com tenantuseMelissaAgenda._reloadRange(Melissa FullCalendar) — expande no range visívelusePatientSessions.load— range -6mo a +12mo, filtra porpatient_iduseMelissaEventos._fetchRange— expande no range pedido. Cobre widget "Hoje", mini-cal, fallbackuseMelissaTodasSessoesPaciente.fetch— range -6mo a +12mo, filtra porpatient_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:
- Buscar em
agenda_eventosse já existe row materializada (recurrence_id+recurrence_date). - Se sim, UPDATE status nela.
- 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.js—loadAndExpand,expandRules,mergeWithStoredSessions,buildOccurrencesrc/layout/melissa/composables/useMelissaAgenda.js:809—onUpdateSeriesEventsrc/features/agenda/composables/useAgendaEventActions.js:65— watcher do form.statussrc/features/patients/composables/usePatientSessions.js:189—updateStatuscom materializaçãosrc/layout/melissa/MelissaLayout.vue:655—updateEventoStatusdoMelissaEventoPanelsrc/layout/melissa/MelissaAgenda.vue:244—VIEW_MAP.lista = 'listAll'