Content Scheduling Pattern (Padrão de Programação de Conteúdo)
Status: SSOT — Single Source of Truth Escopo: Programação temporal de conteúdos administrados (publicação/despublicação automática) na plataforma OLP. Implementação de referência:
headers_novidades+useHeadersProgramados+ aba "Programados" do especialista.
1. Princípio: Lazy Materialization
O sistema NÃO mantém um campo status materializado para conteúdos programados. Em vez disso, o status é derivado em runtime a partir de três fontes:
| Fonte | Tipo | Significado |
|---|---|---|
publicar_em | timestamptz | Início da janela de exibição. NULL = sem programação. |
despublicar_em | timestamptz (nullable) | Fim opcional. NULL = exibe até desativação manual. |
now() (servidor + cliente) | momento atual | Comparação determinística. |
Status derivado (helper classificarProgramado):
fim != null && fim <= now → 'expirado'
inicio <= now → 'no_ar'
caso contrário → 'aguardando'Por que lazy?
- Sem cron: não dependemos de jobs agendados para "publicar" ou "despublicar". A query já filtra.
- Sem janela de inconsistência: assim que
publicar_em <= now(), o conteúdo aparece. Sem atraso de 1 minuto/cron. - Reversibilidade trivial: editar
publicar_emreflete no próximo refetch, sem migração de status. - Auditoria preservada: histórico de mudanças vive nos logs (
registrarLog), não em transições de status.
2. Defesa em Profundidade
A janela temporal é aplicada em três camadas:
Camada 1 — RLS (banco)
A política RLS de SELECT para o público filtra por janela:
CREATE POLICY "publico_le_ativos_na_janela" ON headers_novidades
FOR SELECT TO anon, authenticated
USING (
ativo = true
AND ordem_no_carrossel IS NOT NULL
AND (publicar_em IS NULL OR publicar_em <= now())
AND (despublicar_em IS NULL OR despublicar_em > now())
);Camada 2 — Query (Edge Function)
A action list_active aplica o mesmo filtro explicitamente:
.or(`publicar_em.is.null,publicar_em.lte.${agoraIso}`)
.or(`despublicar_em.is.null,despublicar_em.gt.${agoraIso}`)Motivo: se a RLS for afrouxada acidentalmente em uma migration futura, headers fora da janela continuam invisíveis.
Camada 3 — Frontend (consumo reativo)
O hook useHeadersAtivos consome o resultado já filtrado. Lazy materialization no servidor exige tick no cliente — sem ele, o estado derivado só seria reconciliado em refetch oportunista (focus/navegação) e o usuário poderia ficar minutos sem ver um header recém-publicado.
A reatividade é garantida em três sub-camadas combinadas:
- Tick periódico —
refetchInterval: 60_000(erefetchIntervalInBackground: falsepara não consumir CPU em abas inativas). - Tick de foco —
refetchOnWindowFocus: truecobre quem volta de outra aba/janela. - Invalidação programada —
useScheduleInvalidationagendasetTimeoutpara cadapublicar_em/despublicar_emfuturo conhecido (cap de 24h, +250ms de margem). Garante reflexo exato no instante da virada, sem depender do tick.
Por que as três? O tick sozinho introduz até 60s de atraso. O timer programado sozinho perde marcas além do
maxDelayMse qualquer header criado/editado entre dois fetches. O foco sozinho assume usuário ativo. As três camadas convergem para "no pior caso, 60s; no melhor caso, instantâneo".
Audiência por adesão à olimpíada
Quando header.olimpiada_id IS NOT NULL, o list_active filtra pela adesão da escola (escola_olimpiadas.status='ativa'). Esse lookup usa service_role (createSupabaseSystem) porque a tabela escola_olimpiadas não expõe policy para anon — escopo restrito a escola_id validado + status='ativa', sem vazamento de dados ao cliente. Ver ADR-011.
3. Modelo de Dados
ALTER TABLE headers_novidades
ADD COLUMN publicar_em timestamptz,
ADD COLUMN despublicar_em timestamptz;
-- Constraint de integridade temporal
ALTER TABLE headers_novidades
ADD CONSTRAINT headers_novidades_janela_valida
CHECK (despublicar_em IS NULL OR despublicar_em > publicar_em);
-- Índices para o filtro de janela
CREATE INDEX idx_headers_publicar_em ON headers_novidades (publicar_em)
WHERE publicar_em IS NOT NULL;
CREATE INDEX idx_headers_despublicar_em ON headers_novidades (despublicar_em)
WHERE despublicar_em IS NOT NULL;Estados possíveis
ativo | publicar_em | despublicar_em | Estado lógico |
|---|---|---|---|
false | NULL | NULL | Rascunho (não aparece em lugar algum público) |
true | NULL | NULL | Publicado manualmente (sem programação, ativo até toggle) |
true | futuro | NULL ou futuro | Aguardando (programado, ainda não no ar) |
true | passado | NULL ou futuro | No ar |
true | qualquer | passado | Expirado (some do público; ainda visível na timeline da gestão) |
4. API (Edge Function especialista-headers)
list_programados (autenticada — gestão)
Retorna todos os headers que possuem publicar_em IS NOT NULL, incluindo expirados — a UI agrupa por bucket temporal.
programar_header (autenticada)
{ id, publicar_em, despublicar_em? }- Define a janela e marca
ativo = true. - Auto-atribui
ordem_no_carrossel = MAX+1quando ainda nulo (evita conflito com headers já publicados). - Pode reprogramar, adiar ou alterar fim de qualquer header.
- Não usar para headers já ativos sem programação (UX: "desative → programe" ou "edite janela diretamente").
desprogramar_header (autenticada)
{ id }- Limpa
publicar_em,despublicar_em,ordem_no_carrossel. - Marca
ativo = false→ header volta para "Desativado" na tab Controle.
list_active (pública)
Já documentada em camada 2 acima.
5. Cache (React Query)
Query keys
| Key | Hook | Conteúdo |
|---|---|---|
['headers-novidades'] | useHeadersNovidades | Tudo (gestão — tab Controle) |
['headers-programados'] | useHeadersProgramados | Apenas com publicar_em (tab Programados) |
['headers-ativos'] | useHeadersAtivos | Filtro público (carrossel home) |
Regra de invalidação
Toda mutation de programação invalida AS TRÊS keys para manter coerência cross-tab.
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['headers-programados'] });
queryClient.invalidateQueries({ queryKey: ['headers-novidades'] });
queryClient.invalidateQueries({ queryKey: ['headers-ativos'] });
}Optimistic updates
programar_header e desprogramar_header aplicam optimistic em ambas as caches de gestão (programados + headers) usando snapshots restauráveis. headers-ativos é apenas invalidada (servidor é a fonte da verdade para o público).
6. UX (tab "Programados")
A timeline é organizada em buckets temporais, na ordem:
- NO AR — atualmente exibidos
- HOJE — começarão ainda hoje
- ESTA SEMANA — começam até o fim da semana
- PRÓXIMA SEMANA
- MAIS TARDE
- EXPIRADOS — saíram do ar (bucket informativo, não bloqueante)
Loading states são localizados por item (desprogramandoId === h.id) para evitar spinners globais que congelam toda a lista.
Sequência criação + programação
Quando o usuário programa um header novo (sem registro prévio), o frontend executa:
1. criarHeader({ ativo: false }) // rascunho órfão
2. programarHeader({ id, publicar_em }) // janela
└─ falhou? → deletarHeader(id) // rollbackRazão do rollback: sem ele, falhas no passo 2 deixariam rascunhos no banco que o usuário não solicitou.
7. Segurança & Logging
- RLS de mutation:
programar_headeredesprogramar_headerexigem papelespecialistaouadministrador(gate viarequireRole). - Feature flag:
header_novidades(gestão) eheader_novidades_consumo(carrossel) são independentes — desligar uma não afeta a outra. Pausa intencional do consumo retorna HTTP 200 +code='FEATURE_DISABLED'(não 503), evitando retry exponencial no cliente; 503 fica reservado para falha real de leitura da flag (FLAG_READ_ERROR). Verdocs/features/HEADER_NOVIDADES.md#feature-flags-escopo-segregado. - Log:
registrarLogcomacao: 'header.programar' | 'header.desprogramar'e diff completo (antes/depois) emalteracoes.
8. Aplicabilidade a Outras Features
Este padrão é mandatório para qualquer conteúdo administrado que precise de janela temporal automática. Candidatos imediatos:
- Banners de login (
banners_login) — já temactivated_at; faltapublicar_em/despublicar_em. - Mural Olímpico — anúncios programados.
- Mensagens em massa — agendamento de envio.
Ao replicar:
- Sempre as três camadas (RLS + query + UI).
- Sempre lazy materialization (não criar campo
status). - Sempre invalidar cross-cache nas mutations.
- Sempre rollback em sequências de criação multi-passo.
- Sempre combinar tick periódico + onFocus +
useScheduleInvalidationno consumo público (lazy server exige tick client).
9. Ver também
src/hooks/useHeadersProgramados.ts— hook de referência (optimistic + cross-invalidate)src/hooks/useScheduleInvalidation.ts— agendador de invalidação por instante (Bloco 2 da reatividade)src/components/header-novidades-especialista/tab-programados/helpers.ts— classificação e agrupamentosupabase/functions/especialista-headers/index.ts— actionslist_active,list_programados,programar_header,desprogramar_headerdocs/development/REACT_QUERY_CACHE.md— padrões de cachedocs/security/RLS_DESIGN_GUIDE.md— RLS por designADR-011 — service_role vs RLS— quando usarcreateSupabaseSystemem consultas públicas
10. Histórico de incidentes resolvidos
| Data | Sintoma | Causa raiz | Correção |
|---|---|---|---|
| 2026-04-25 | Header programado para 22:33 não aparecia para coordenador às 22:35 | refetchOnWindowFocus: false + ausência de refetchInterval no consumo. Lazy materialization no servidor sem tick no cliente. | Habilitado refetchOnWindowFocus: true + refetchInterval: 60s + useScheduleInvalidation para invalidar exatamente em publicar_em/despublicar_em. |
| 2026-04-25 | Headers vinculados a olimpíadas (ex.: Canguru) não apareciam mesmo com adesão ativa | escola_olimpiadas sem policy para anon; lookup público falhava silenciosamente; fail-close escondia todos os vinculados. | Migrado o lookup de adesão em list_active para createSupabaseSystem (service_role), com escopo restrito a escola_id validado + status='ativa'. |
Lazy Reconciliation Inversa (Headers Placeholders, Abr/2026)
Variante do padrão de reconciliação preguiçosa onde o estado-alvo depende inversamente de outro recurso. Headers Placeholders se desativam quando existem novidades ativas, e se reativam (a partir de snapshot) quando não existem.
- Disparo: mutações do recurso "primário" (novidades) chamam
reconciliarPlaceholdersVsNovidades()em try/catch. - Snapshot: coluna dedicada (
ativo_antes_auto_off) preserva a intenção do usuário antes do auto-off. Toggle manual sobrescreve o snapshot. - Best-effort: falhas de reconciliação não bloqueiam a action principal — apenas
console.warn. Próxima leitura/mutação reconcilia novamente.
Ver docs/features/HEADER_NOVIDADES.md (seção "Headers Placeholders").