Auditoria — Resultados do Coordenador: "Nível S (3º EM) sumindo"
Data: 2026-06-04 Escopo: @audit feature resultados-coordenadorGatilho: Bug reportado por Ivan — "Ao publicarmos o resultado, percebemos que nada referente ao Nível S (3º Ano EM) está aparecendo, mesmo a planilha sendo upada." Status: ✅ Causa raiz CONFIRMADA (H6 — publicação parcial silenciosa). H1–H5 refutadas com dados de produção. Correção em PR separado.
Este documento é também o primeiro exemplo aplicado do escopo
@audit featureformalizado em AUDIT.md §Parte F. Use-o como referência de profundidade esperada para auditorias E2E de features data-heavy.
0. Sumário executivo
| Bug declarado | Status | Causa raiz | Risco |
|---|---|---|---|
| Resultados de Nível S (3º EM) não aparecem no mural após upload da planilha | ✅ Confirmada | H7 — Cap silencioso 1000 do PostgREST na tela "Resultados" do coordenador: o backend (handleList e correlatos) faz select direto em inscricoes_olimpiada sem paginação. Para a escola afetada (1.4k inscrições / Canguru), a query trunca em 1000 linhas — alunos dos níveis S e J (ordenados no final) somem da listagem, dos dropdowns de filtro e dos contadores. Sem alunos na tela = coordenação não publica. Reforça H6 (UX de publicação parcial silenciosa), mas a origem real é o cap. | 🔴 Crítico — perda silenciosa de dados de leitura; padrão arquitetural |
Evidência cruzada (escola Centro de Excelência Master 7a8fdea1-…, Canguru 84a8c346-…):
| Camada | P | E | B | C | J | S |
|---|---|---|---|---|---|---|
importacao_resultados_sessoes (concluída) | ✅ 73 | ✅ 234 | ✅ 406 | ✅ 218 | ✅ 262 | ✅ 224 |
resultados_aluno | 73 | 234 | 406 | 218 | 262 | 224 |
Visível na UI gestao-resultados/list | ⚠️ truncado em 1000 (badge "1000 Participantes" no header é a smoking gun) | |||||
mural_liberacoes (2026-06-04 15:18-15:19) | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ (porque coordenação não viu os alunos S na UI) |
mural_dados_publicados | 73 | 234 | 406 | 218 | 262 | 0 |
Pontos de falha silenciosa (consolidados):
- PFS-7 (CAUSA RAIZ) — 8 queries em
_shared/resultados-queries.tsconsultaminscricoes_olimpiadasem paginação (fetchAllRows/range). PostgREST trunca em 1000 sem erro nem header visível ao cliente JS. Fix: helperfetchAllRowsem_shared/pagination-helpers.ts+ refactor das 8 chamadas. Verdocs/development/LARGE_DATASET_HANDLING.md. - PFS-6 — UI de liberação não exibe progresso "X de Y níveis publicados" nem alerta níveis com resultados sem
mural_liberacoes. Sequela do PFS-7: coordenação tomou decisão certa para o que enxergou. - PFS-4 — Backend
get_liberacao_statssem.order()/.limit()→ mesmo risco latente, mas em outra rota. - PFS-2 —
pontuacaoMaximaPorNivel[nivelNome]por nome literal (bug latente; sem relação com este incidente).
1. Mapa do pipeline
COORDENADOR clica "Inserir resultados"
└─ selecao-metodo-modal.tsx (planilha | manual)
INSERIR (planilha):
inserir-step-selecao.tsx → escolhe olimpiada + fase + nivel
└─ useOlimpiadasCoordenador.buscarDetalhes(olimpiadaId)
→ retorna { fases, niveisPersonalizados, seriesParticipantes[] }
│
▼
inserir-step-upload.tsx → parse xlsx + columnMapping
└─ ExcelJS → rawData → safeExtractValue → safeParseNumber
inserir-dialog.tsx:147-161 → previewResults (linha rejeita se NaN/<0)
inserir-dialog.tsx:164-186 → elegibilidade {
validos,
matriculaNaoEncontrada, ◄ aluno não existe na escola
serieIncompativel, ◄ ★ FILTRO ALVO DA INVESTIGAÇÃO
naoClassificadoFaseAnterior,
}
inserir-step-preview.tsx → mostra contagens
CLICA "Confirmar"
└─ start_import_session (resultados > 50) OR insert_lote (≤ 50)
→ edge fn gestao-resultados
→ _shared/resultados-queries.handleInsertLote
→ resultados_aluno UPSERT (matricula, fase_id, nivel_id, pontuacao)
LISTAGEM (após salvar):
resultados-detalhes.tsx
└─ useResultadosPorOlimpiadaPaginado(olimpiadaId, faseId, {nivelId, ...})
→ action "list"
→ _shared/resultados-queries.handleList
├─ busca inscritos via olimpiada_series_participantes ∩ niveis_competicao_series
│ resultados-queries.ts:440 → alunosDoNivel
└─ left join resultados_aluno
→ tabela renderizada
PUBLICAÇÃO NO MURAL (gate confirmado como causa raiz):
mural-liberacoes.tsx (Coordenador)
└─ get_liberacao_stats → painel mostra níveis com dados
└─ Coordenador clica "Publicar" POR NÍVEL (tabs)
└─ mural_liberacoes UPSERT (escola, fase, nivel, liberar_resultados)
└─ recomputeSnapshotsIfPublished (snapshot-publicados.ts:439)
└─ computeAndUpsertSnapshot (por liberação existente)
→ mural_dados_publicados (1 row por aluno × tipo)
✦ SE NÃO EXISTE mural_liberacoes(fase, nivel) → snapshot não roda
→ mural_dados_publicados fica vazio para esse nível
→ portal/mural não mostra nada (sem erro, sem aviso)Pontos de filtragem onde Nível S pode desaparecer (numerados para referência das hipóteses):
| # | Local | Critério | Falha silenciosa? |
|---|---|---|---|
| F1 | inserir-dialog.tsx:155 | pontuacao >= 0 && !NaN | Só console.warn |
| F2 | inserir-dialog.tsx:178-181 | série do aluno ∈ seriesParticipantesIds | Sim — contador no preview mas não no toast final |
| F3 | inserir-dialog.tsx:182 | classificado na fase anterior | Sim, mesmo padrão |
| F4 | inserir-dialog.tsx:133-134 | pontuacaoMaximaPorNivel[nivelNome] por nome literal | Sim — vira null sem warning |
| F5 | _shared/resultados-queries.ts:440 | alunosDoNivel montado via mapeamento níveis↔séries | Sim — tabela vazia |
| F6 | RLS resultados_aluno | filtro por escola_id + nível | Sim — retorna 0 linhas |
| F7 | mural-escola/get_liberacao_stats:968 | .select() sem .order().limit() — default cap 1000 PostgREST | Sim — alguns níveis podem sumir do painel em escolas grandes |
| F8 | mural-liberacoes.tsx (publicação por tab) | publicação nível-a-nível sem indicador global de progresso | Sim — CAUSA RAIZ confirmada |
| F9 | snapshot-publicados.ts:474 | itera apenas mural_liberacoes existentes | Sim — nível sem linha = silêncio total no mural |
2. Inventário de dados envolvidos
| Tabela | Coluna(s) relevante(s) | Papel no fluxo |
|---|---|---|
olimpiadas | id, tipo_olimpiada, modo_config_fases (global|por_nivel) | Define se config de premiação/série é única ou por nível |
olimpiada_edicoes | id, olimpiada_id, ano_edicao | Edição corrente (filtra olimpiada_series_participantes) |
olimpiada_series_participantes | olimpiada_id, serie_id | F2 — fonte direta de seriesParticipantesIds |
niveis_competicao | id, nome, olimpiada_id | "Nível S" tem que ter row aqui |
niveis_competicao_series | nivel_id, serie_id | F5 — mapeia Nível S → séries (3º EM) |
series_escolares | id, label, ordem | "3º Ano EM" precisa existir e estar ativo |
fases_olimpiada | id, pontuacao_maxima_por_nivel (jsonb) | F4 — JSON keyed pelo nome do nível |
alunos | id, matricula, turma_id, escola_id | Lookup por matrícula |
turmas | id, serie_id, identificador | Liga aluno → série |
resultados_aluno | aluno_id, fase_id, nivel_id, pontuacao, situacao | Tabela final, alvo do INSERT |
inscricoes_olimpiada | aluno_id, olimpiada_id, ano_edicao, status | Backend filtra inscritos quando lista |
3. Matriz de cenários (coordenador inserindo resultados)
| # | Cenário | Comportamento esperado | Onde quebra hoje |
|---|---|---|---|
| C1 | Aluno do 3º EM, série 3º EM em olimpiada_series_participantes, Nível S existe, mapeamento niveis_competicao_series(NS, 3ºEM) presente | Insere normalmente | OK |
| C2 | Mesma config, mas olimpiada_series_participantes não contém 3º EM | Coordenador deveria ver "X alunos em série incompatível" em destaque + bloqueio | Toast final mascara — bug 3 |
| C3 | Mapeamento niveis_competicao_series vazio para Nível S | Backend lista alunosDoNivel = ∅ → tabela vazia | F5 — sem warning |
| C4 | Aluno do 3º EM com turma_id = NULL ou turmas.serie_id = NULL | Cai em serieIncompativel | F2 silencioso |
| C5 | Nível "Nível S" no DB, planilha esperando "S" no dropdown UI | Match por id no UI funciona; nome só importa para F4 | OK exceto F4 |
| C6 | pontuacao_maxima_por_nivel = {"S": 120} mas niveis_competicao.nome = "Nível S" | F4 retorna null, sem limite máximo aplicado | Bug latente |
| C7 | Modo por_nivel com fase intermediária premiando Nível S | Stats dependem de §B3 do audit anterior | Já mapeado em AUDIT_RESULTADOS_DASHBOARD_2026-06-04.md |
| C8 | Fase anterior sem nenhum classificado de Nível S | Todos vão para naoClassificadoFaseAnterior | F3 silencioso |
| C9 | Matrícula da planilha com espaços/zero à esquerda divergente do banco | matriculaNaoEncontrada | F1 reportado, mas pode confundir com "série incompatível" |
| C10 | OMU (mapeamento omu-levels-series-mapping) — Nível 3 = 1º+2º+3º EM | Mesmo aluno 3º EM deveria entrar; depende de niveis_competicao_series correto | Mesma falha potencial F5 |
| C11 | Canguru por nível — Nível S premiado em fase ≠ última | Cards de medalha somem (B2 do audit anterior) | Já tratado |
| C12 | Edição nova (ano_edicao=2026) sem rodar setup de séries para Nível S | C3 + C2 simultâneos | Hipótese H1 deste audit |
| C13 | Planilha com Nível S misturado a outros níveis e coordenador escolheu nível ≠ S | Linhas de outros níveis viram serieIncompativel (turmas fora do recorte do nível ativo) | F2 ambíguo |
4. Pontos de falha silenciosa
| ID | Descrição | Severidade |
|---|---|---|
| PFS-1 | serieIncompativel contado no preview, mas o toast de sucesso após insert_lote reporta Importação concluída! N resultado(s) — sem informar quantos foram descartados | 🔴 |
| PFS-2 | pontuacaoMaximaPorNivel[nivelNome] fallback null sem warning visível | 🟡 |
| PFS-3 | seriesParticipantesIds.length === 0 → o filtro F2 é pulado (passa tudo). Mascara o bug oposto: olimpíadas mal-configuradas parecem funcionar | 🔴 |
| PFS-4 | Backend handleList retorna lista vazia para nível sem mapeamento sem distinguir "sem alunos elegíveis" de "config faltando" | 🟡 |
| PFS-5 | niveis_competicao_series sem trigger de integridade — uma edição pode existir sem nenhuma série mapeada para um nível ativo | 🟡 |
| PFS-6 | (CAUSA RAIZ) UI mural-liberacoes.tsx aceita publicação parcial silenciosamente: sem progresso "X de Y níveis publicados", sem badge "pendente" em níveis com resultados mas sem mural_liberacoes, sem aviso ao sair. Resultado: o coordenador não percebe que esqueceu um nível e o portal fica vazio para aquele recorte | 🔴 |
| PFS-7 | mural-escola/get_liberacao_stats faz .select() sem .order()/.limit() — cap default 1000 do PostgREST pode esconder níveis em escolas com >1000 resultados_aluno na olimpíada (escola atual: 1417 rows; risco latente) | 🟡 |
5. Cobertura de testes — atual vs necessária
Hoje:
src/lib/__tests__/resultados-preview.test.ts— só helpers puros (safeParseNumber, detectColumn).src/hooks/__tests__/useGestaoResultados.integration.test.ts— invalidação de cache.e2e/resultados-premiacao-por-nivel.spec.ts— fluxo de premiação, não cobre insert por nível.- Nenhum teste cobre: F2 (série incompatível), F4 (pontuacaoMaxima por nome), F5 (alunosDoNivel vazio).
Necessário (entra no TESTING_BACKLOG.md — Foco Notável):
- RTL
inserir-dialog.test.tsx— matriz C1-C13. - Deno
gestao-resultados/__tests__/insert-lote-nivel-s.test.ts—niveis_competicao_seriesvazio retorna 0 inseridos com mensagem clara. - Deno
_shared/__tests__/resultados-queries-alunos-do-nivel.test.ts— F5 isolado. - E2E coordenador — fluxo completo Nível S Canguru.
- Contract —
olimpiada_series_participantes∩niveis_competicao_seriesconsistência por edição.
6. Regras de negócio aplicáveis
mem://features/results/comprehensive-architecture-standard— arquitetura geral de resultados.mem://features/results/dashboard-cards-by-fase-config— gating de cards (B7 cleanup).mem://business/olympics-operational-integrity— integridade operacional.mem://business/omu-levels-series-mapping— OMU usa Nível 3 = 1ºEM+2ºEM+3ºEM (cenário C10).mem://business/pedagogical-series-sorting-standard— ordem canônica das séries.mem://architecture/olympics-phases-by-level-standard— modopor_nivel.mem://security/results-eligibility-and-mural-visibility-standard— elegibilidade backend.mem://debugging/payload-trust-check— conferir payload no Network ANTES da UI (vale aqui parabuscarDetalhes→seriesParticipantes).
7. Checklist de validação em produção
Executar na ordem contra a escola que reportou o bug (substituir :escola_id, :olimpiada_id, :edicao_ano):
-- 7.1 — A série "3º EM" existe e está ativa?
SELECT id, label, ordem
FROM series_escolares
WHERE label ILIKE '%3%EM%' OR label ILIKE '%3º%médio%' OR label ILIKE '%terceiro%médio%'
ORDER BY ordem;
-- 7.2 — O Nível S está cadastrado para a olimpíada?
SELECT id, nome, olimpiada_id
FROM niveis_competicao
WHERE olimpiada_id = :olimpiada_id
ORDER BY nome;
-- Esperado: 1 row com nome contendo "S" (validar acentuação/case)
-- 7.3 — F5: Nível S está mapeado para a série 3º EM?
SELECT ncs.nivel_id, nc.nome AS nivel_nome, ncs.serie_id, se.label AS serie_label
FROM niveis_competicao_series ncs
JOIN niveis_competicao nc ON nc.id = ncs.nivel_id
JOIN series_escolares se ON se.id = ncs.serie_id
WHERE nc.olimpiada_id = :olimpiada_id
ORDER BY nc.nome, se.ordem;
-- Esperado: pelo menos 1 row (Nível S, 3º EM). Ausência = causa raiz H1/H3.
-- 7.4 — F2: A edição da olimpíada inclui 3º EM em olimpiada_series_participantes?
SELECT osp.olimpiada_id, osp.serie_id, se.label
FROM olimpiada_series_participantes osp
JOIN series_escolares se ON se.id = osp.serie_id
WHERE osp.olimpiada_id = :olimpiada_id
ORDER BY se.ordem;
-- Esperado: row para série 3º EM. Ausência = causa raiz H1.
-- 7.5 — F4: pontuacao_maxima_por_nivel tem chave compatível com nome do nível?
SELECT id, nome AS fase, pontuacao_maxima_por_nivel
FROM fases_olimpiada
WHERE olimpiada_id = :olimpiada_id
ORDER BY ordem;
-- Inspecionar visualmente: chaves do JSON devem === niveis_competicao.nome
-- 7.6 — Alguma matrícula da escola tem aluno do 3º EM?
SELECT COUNT(*) AS total_alunos_3em
FROM alunos a
JOIN turmas t ON t.id = a.turma_id
JOIN series_escolares se ON se.id = t.serie_id
WHERE a.escola_id = :escola_id
AND (se.label ILIKE '%3%EM%' OR se.label ILIKE '%terceiro%médio%');
-- 7.7 — Algum resultado de Nível S **chegou** ao banco?
SELECT COUNT(*) AS total_resultados_nivel_s,
COUNT(DISTINCT aluno_id) AS alunos_distintos
FROM resultados_aluno ra
JOIN niveis_competicao nc ON nc.id = ra.nivel_id
JOIN alunos a ON a.id = ra.aluno_id
WHERE a.escola_id = :escola_id
AND nc.olimpiada_id = :olimpiada_id
AND nc.nome ILIKE '%S%';
-- Se > 0: bug é só de LISTAGEM (F5/F6). Se = 0: bug é de INSERT (F2/F3/F4).Resultado dos 7 pontos isola hipótese em §8.
7.8 — Resultados executados em produção (2026-06-04, escola 7a8fdea1-…, Canguru 84a8c346-…)
| Query | Resultado |
|---|---|
| 7.1 série 3º EM | ✅ serie_id=3_ano_em, ordem 12 |
| 7.2 Nível S cadastrado | ✅ id=9344ed00-…, titulo "Nível S" |
7.3 mapeamento niveis_competicao_series(S, 3º EM) | ✅ presente |
7.4 olimpiada_series_participantes(3º EM) | ✅ presente |
7.5 fase.pontuacao_maxima_por_nivel | ✅ "Nível S": 150 (case correto) |
| 7.6 alunos 3º EM na escola | ✅ > 200 |
| 7.7 resultados Nível S no banco | ✅ 224 rows, status concluida |
+ mural_liberacoes (Canguru/escola) | ❌ 5 rows (P/E/B/C/J), 0 para Nível S ← causa raiz |
+ mural_dados_publicados (Canguru/escola) | 5 níveis presentes, 0 para Nível S (consequência) |
→ Falha está entre resultados_aluno (✅ 224) e mural_liberacoes (❌ ausente). Hipótese H6 (publicação parcial silenciosa) confirmada; H1–H5 refutadas.
8. Hipóteses ranqueadas
| # | Hipótese | Status final | Evidência |
|---|---|---|---|
| H1 | olimpiada_series_participantes da edição não inclui 3º EM | ❌ Refutada | 7.4 retornou 3_ano_em presente |
| H2 | niveis_competicao_series(Nível S, 3º EM) ausente | ❌ Refutada | 7.3 retornou mapeamento presente |
| H3 | Aluno do 3º EM com turma_id/serie_id nula | ❌ Refutada | 224 inscrições foram criadas com nivel_id=Nível S a partir de matrículas válidas |
| H4 | pontuacaoMaximaPorNivel por nome literal divergente | ❌ Refutada (na origem) | 7.5 mostrou "Nível S": 150 correto. Mantém-se como bug latente PFS-2. |
| H5 | RLS de resultados_aluno filtrando out | ❌ Refutada | Snapshot usa service_role (bypassa RLS) |
| H6 | Publicação parcial silenciosa — coordenador publica nível-a-nível e esquece o S; sem mural_liberacoes o snapshot nunca roda → mural_dados_publicados vazio | ✅ CONFIRMADA | Timestamps das 5 liberações (15:18-15:19 em 51 s, P→E→B→C→J) + ausência total de row para Nível S em mural_liberacoes + 0 rows em mural_dados_publicados para Nível S, com 224 rows em resultados_aluno |
9. Plano de correção (PR separado — fora do escopo desta auditoria)
9.1 Backend (mural-escola)
get_liberacao_stats(mural-escola/index.ts:968): adicionar.order('id').limit(50000)ou paginar viaqueryInChunks. Hoje cai no cap default 1000 do PostgREST com 1417 rows na escola auditada.- Novo action
get_niveis_pendentes_publicacao: retorna{ fase_id, nivel_id, titulo, resultados_count, publicado: boolean }cruzandoresultados_aluno × mural_liberacoes— alimenta o indicador na UI.
9.2 Frontend (mural-liberacoes.tsx)
- Header do card de cada olimpíada: badge "X de Y níveis publicados" calculado de
niveis × liberações existentes. - Botão de cada nível: ícone amarelo "pendente" quando há resultados sem
mural_liberacoes. - Modal de confirmação ao sair da tela com publicações incompletas.
- Botão opcional "Publicar todos os níveis com resultados" (com confirmação) — atalho para evitar o erro humano.
9.3 Backfill da escola afetada
Script idempotente que cria mural_liberacoes(liberar_resultados=true) para Nível S da escola Centro de Excelência Master + Canguru com config_resultados idêntico aos outros níveis, e dispara recomputeSnapshotsIfPublished — preenche mural_dados_publicados com os 224 registros.
9.4 Testes
Catalogados em TESTING_BACKLOG.md — "Foco notável — Publicação parcial silenciosa".
10. Memórias criadas/atualizadas
- ✅ Criada
mem://features/mural-olimpico/publicacao-parcial-silenciosa(regra UX para qualquer tela de publicação por nível). - ✅ Atualizada
mem://features/results/comprehensive-architecture-standard(gatemural_liberacoes→mural_dados_publicados). - Atualizar
mem://debugging/payload-trust-checkcom o casomural_dados_publicados(em backlog do próximo PR).
11. Links
- Audit anterior (cards/stats): AUDIT_RESULTADOS_DASHBOARD_2026-06-04.md
- Refactor inicial: RESULTADOS_REFACTOR_AUDIT.md
- Padrão do escopo: AUDIT.md §Parte F
- Backlog atualizado: TESTING_BACKLOG.md
12. H8 — Truncamento no recompute (descoberto 2026-06-04 pós-fix UI)
12.1 Sintoma
Após corrigir o cap de 1000 linhas nas queries de leitura (resultados-queries.ts), os alunos do Nível S/J voltaram a aparecer na UI, mas todos os 224 resultados do Nível S permaneceram com situacao = 'aguardando', mesmo após a coordenadora re-salvar as faixas de pontuação. 193 dos 262 do Nível J idem.
12.2 Causa raiz validada
supabase/functions/_shared/resultados-compute.ts → recomputeFaseForOlimpiada carregava as inscrições com .select("id, nivel_id").eq("escola_id", …).eq("olimpiada_id", …) sem paginação. Centro de Excelência Master tem 1417 inscrições no Canguru → PostgREST truncou em 1000 → 417 inscrições (incluindo todas as 224 do Nível S) ficaram fora de inscricaoToNivel e inscricaoIds. Seus resultados_aluno nunca eram lidos nem atualizados, ficando congelados em aguardando desde o import.
12.3 Evidência DB (timestamp prova o pulo)
| Nível | Total | Aguardando | MAX(atualizado_em) |
|---|---|---|---|
| P / E / B / C | 73–406 | 0 | 16:57:49–53 (recomputado) |
| J | 262 | 193 | 16:57:53 (parcial) |
| S | 224 | 224 | 15:28:52 (timestamp do import — nunca recomputado) |
Resultado com pontuação 116.00 / 107.50 (faixa de ouro 107.5–150) marcado como aguardando — incompatível com config salva às 18:52:33.
12.4 Por que a refatoração anterior não cobriu
PR de 2026-06-04 aplicou fetchAllRows apenas em resultados-queries.ts (caminho de leitura para a UI). O caminho de escrita/recompute em resultados-compute.ts (arquivo irmão) seguiu com .select() direto. Mesmo bug, módulo diferente.
12.5 Fix aplicado
resultados-compute.ts: troca parafetchAllRowscom labelrecomputeFaseForOlimpiada.inscricoes, comentário com referência a este incidente._shared/__tests__/recompute-pagination.test.ts: regressão drenando 1417 inscrições (medalha_ouro em 100%).mem://index.mdCore: cláusula reforçada — vale para leitura E recompute/jobs.
12.6 Backfill
Coordenadora deve re-salvar as faixas do Nível S (ou clicar em "recompute") após o deploy. A próxima invocação processará todas as 1417 inscrições e aplicará as faixas corretas.
13. H9 — Contraste invisível de Sem Premiação / Aguardando no portal do aluno
13.1 Sintoma
Após corrigir H7/H8 (cap silencioso de 1000 rows em leitura e recompute), a coordenadora reportou que o aluno do EF I (Nível P, Canguru) "não vê nada na premiação" — a tela aparece com o card "DETALHES" mostrando label SUA SITUAÇÃO mas sem valor visível ao lado.
13.2 Investigação (NÃO é regressão de H7/H8)
Aluno é EF I, Nível P → dentro do cap de 1000, recompute rodou:
pontuacao = 77.5/120✅ renderiza- "A maior pontuação do nível foi 110/120" ✅ renderiza
situacao = sem_premiacao✅ gravado em banco
DOM inspecionado: o <span> da situação está presente e contém o texto Sem Premiação. O texto está invisível por contraste, não ausente.
13.3 Causa raiz
src/components/mural-olimpico/medal-svg.tsx definia:
sem_premiacao: { color: 'text-white/60' }
aguardando: { color: 'text-white/50' }
// fallback do getSituacaoLabel: 'text-white/60'Cores criadas originalmente para o banner escuro do topo, mas reusadas pelo fase-content.tsx (linha 101) para montar a linha "SUA SITUAÇÃO" dentro do card DETALHES (fundo claro). Branco com 50–60% de opacidade sobre fundo claro → fantasma.
H7/H8 não regrediu — o bug visual era pré-existente e ficou exposto agora que sem_premiacao é populado em volume (antes da H8, a maioria estava aguardando e o usuário nem chegava no estado de saída).
13.4 Fix aplicado
medal-svg.tsx: paleta refeita para superfície clara.sem_premiacaoeaguardando→text-muted-foreground. Medalhas/classificado/nao_classificado → tons600(têm contraste OK em card claro). Fallback dogetSituacaoLabelidem.__tests__/medal-svg.test.ts: regressão estática que falha se qualquer cor voltar a usartext-white/*.
13.5 Lição
Sintoma "não aparece nada" tem dois caminhos possíveis: dado ausente OU dado invisível. Inspecionar o DOM ANTES de culpar pipeline de dados — vale a regra do mem://ui/visual-diagnosis-protocol. Tokens de cor reusados entre superfícies (banner escuro vs. card claro) são fonte recorrente de invisibilidade.