Skip to content

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:

FonteTipoSignificado
publicar_emtimestamptzInício da janela de exibição. NULL = sem programação.
despublicar_emtimestamptz (nullable)Fim opcional. NULL = exibe até desativação manual.
now() (servidor + cliente)momento atualComparação determinística.

Status derivado (helper classificarProgramado):

fim != null && fim <= now  → 'expirado'
inicio <= now              → 'no_ar'
caso contrário             → 'aguardando'

Por que lazy?

  1. Sem cron: não dependemos de jobs agendados para "publicar" ou "despublicar". A query já filtra.
  2. Sem janela de inconsistência: assim que publicar_em <= now(), o conteúdo aparece. Sem atraso de 1 minuto/cron.
  3. Reversibilidade trivial: editar publicar_em reflete no próximo refetch, sem migração de status.
  4. 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:

sql
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:

ts
.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:

  1. Tick periódicorefetchInterval: 60_000 (e refetchIntervalInBackground: false para não consumir CPU em abas inativas).
  2. Tick de focorefetchOnWindowFocus: true cobre quem volta de outra aba/janela.
  3. Invalidação programadauseScheduleInvalidation agenda setTimeout para cada publicar_em/despublicar_em futuro 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 maxDelayMs e 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

sql
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

ativopublicar_emdespublicar_emEstado lógico
falseNULLNULLRascunho (não aparece em lugar algum público)
trueNULLNULLPublicado manualmente (sem programação, ativo até toggle)
truefuturoNULL ou futuroAguardando (programado, ainda não no ar)
truepassadoNULL ou futuroNo ar
truequalquerpassadoExpirado (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)

ts
{ id, publicar_em, despublicar_em? }
  • Define a janela e marca ativo = true.
  • Auto-atribui ordem_no_carrossel = MAX+1 quando 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)

ts
{ 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

KeyHookConteúdo
['headers-novidades']useHeadersNovidadesTudo (gestão — tab Controle)
['headers-programados']useHeadersProgramadosApenas com publicar_em (tab Programados)
['headers-ativos']useHeadersAtivosFiltro público (carrossel home)

Regra de invalidação

Toda mutation de programação invalida AS TRÊS keys para manter coerência cross-tab.

ts
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:

  1. NO AR — atualmente exibidos
  2. HOJE — começarão ainda hoje
  3. ESTA SEMANA — começam até o fim da semana
  4. PRÓXIMA SEMANA
  5. MAIS TARDE
  6. 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)       // rollback

Razã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_header e desprogramar_header exigem papel especialista ou administrador (gate via requireRole).
  • Feature flag: header_novidades (gestão) e header_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). Ver docs/features/HEADER_NOVIDADES.md#feature-flags-escopo-segregado.
  • Log: registrarLog com acao: 'header.programar' | 'header.desprogramar' e diff completo (antes/depois) em alteracoes.

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á tem activated_at; falta publicar_em/despublicar_em.
  • Mural Olímpico — anúncios programados.
  • Mensagens em massa — agendamento de envio.

Ao replicar:

  1. Sempre as três camadas (RLS + query + UI).
  2. Sempre lazy materialization (não criar campo status).
  3. Sempre invalidar cross-cache nas mutations.
  4. Sempre rollback em sequências de criação multi-passo.
  5. Sempre combinar tick periódico + onFocus + useScheduleInvalidation no 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 agrupamento
  • supabase/functions/especialista-headers/index.ts — actions list_active, list_programados, programar_header, desprogramar_header
  • docs/development/REACT_QUERY_CACHE.md — padrões de cache
  • docs/security/RLS_DESIGN_GUIDE.md — RLS por design
  • ADR-011 — service_role vs RLS — quando usar createSupabaseSystem em consultas públicas

10. Histórico de incidentes resolvidos

DataSintomaCausa raizCorreção
2026-04-25Header programado para 22:33 não aparecia para coordenador às 22:35refetchOnWindowFocus: 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-25Headers vinculados a olimpíadas (ex.: Canguru) não apareciam mesmo com adesão ativaescola_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").