Repository navigation
Description
Convertida de [Feedback] O Cético Sênior · 2026-07-26 — ICE 5×5×3 = 75.
Problema
O Ranking Global ordena por WPM bruto misturando linguagens e dificuldades diferentes no mesmo pote. Como o snippet easy é curto e o hard é longo, o WPM de um bucket não é comparável com o de outro — o ranking passa a medir qual bucket o jogador escolheu, não quem digita mais rápido.
Prova colhida na própria produção (seção PARTIDAS RECENTES, 2026-07-26):
| jogador | wpm | lang | dificuldade |
|---|---|---|---|
VINNY O RAPIDO |
35 | SQL | hard |
VINNY O RAPIDO |
76 | SQL | easy |
Mesmo jogador, mesma linguagem, 41 WPM de diferença só pela dificuldade — e é o 76 que está cravado em 🥇 no topo de "MELHORES WPM". A página tem zero filtros (0 <select>, 0 botões de filtro no DOM).
Consequência: para ser nº1 não é preciso digitar mais rápido, basta descobrir a combinação linguagem+dificuldade com o snippet mais curto e repetir. Isso esvazia o ranking para o público competitivo — que é justamente quem a página serve (docs/UI-AAA-OVERHAUL.md §III.8: "o competitivo vem aqui medir-se").
Contexto no código
src/app/leaderboard/page.tsx:34-37— SSR chamagetLeaderboard(25)sem nenhum parâmetro de filtro.src/lib/supabase.ts:49-67—getLeaderboard()lê a viewleaderboarde já aplica o teto de leitura.lte("wpm", MAX_PLAUSIBLE_WPM)([Feature] Filtrar valores irreais de WPM no ranking #68/Anti-cheat/Leaderboard: recorde impossível (3596 WPM) no Ranking Global — sem teto de plausibilidade #28). Manter.⚠️ Armadilha principal:supabase/migrations/0001_coderacer_init.sql:43-54define a view como
select distinct on (lower(name)) ... from scores order by lower(name), wpm desc— ou seja, o PB já foi escolhido globalmente antes de qualquer filtro. Adicionar um.eq("language", …)em cima da view NÃO funciona: um jogador cujo PB global ésql/easysimplesmente desaparece da lista ao filtrar porsql/hard, em vez de aparecer com o seu melhor tempo naquele bucket.
Abordagem sugerida (1 fatia, sem migração)
getLeaderboard()ganha um parâmetro de filtro opcional. Sem filtro → caminho atual (viewleaderboard, comportamento preservado). Com filtro → consultascoresdiretamente com.eq("language", …)/.eq("difficulty", …)/.gte("created_at", …), mantendo.lte("wpm", MAX_PLAUSIBLE_WPM), e faz o distinct por nick em TS (helper puro e testável, ex.bestPerName(rows)).- Buscar folga (
limit * k) antes de deduplicar, senão o corte de 25 linhas acontece antes do dedupe e a lista fica curta.
- Buscar folga (
- A página lê os filtros de
searchParams(?lang=,?diff=,?period=) — continua SSR, sem"use client"na página (a rota já édynamic = "force-dynamic"). Chips =<Link>que só trocam a query string, preservando SEO. - Validar os params contra allowlist já existente (
langById/ a allowlist desupabase/migrations/0004_settings_allowlist.sql) antes de tocar no Postgres; valor inválido cai no default.
Acceptance criteria
-
/leaderboardsem query param se comporta exatamente como hoje (PB global por nick, teto de WPM aplicado). -
/leaderboard?lang=sql&diff=hardmostra o ranking restrito ao bucket: um jogador com 76 emsql/easye 35 emsql/hardaparece com 35 — e não some da lista. - Filtro de período
hoje/semana/todosfunciona sobrescores.created_at. - O teto
.lte("wpm", MAX_PLAUSIBLE_WPM)continua aplicado em todos os caminhos, filtrados ou não. - Chips navegáveis por teclado, com o estado atual anunciado (
aria-pressed/role="tablist");prefers-reduced-motionrespeitado. - Query param inválido (
?lang=naoexiste,?diff=impossivel) não quebra a página nem chega cru à query. - Teste unitário do helper de dedupe por nick (empate de WPM → desempate estável).
- Gate verde:
pnpm typecheck·pnpm build·node scripts/validate-persistence.mjs.
Fora do escopo (fatia seguinte)
Separar ranqueado de casual — hoje corridas de 1 jogador (1p) entram no mesmo ranking das disputadas (docs/UI-AAA-OVERHAUL.md, seção de integridade ~linha 1032: "só partidas validadas entram no leaderboard global"). Exige decidir a regra e cruzar scores com matches.player_count; vira issue própria depois desta.
Activity
caioross commentedon Jul 28, 2026
🏛️ Parecer do Conselho · 2026-07-28
Veredito: APROVADA PARA EXECUÇÃO
Engenharia: (a) Não precisa de migration — e isso não é óbvio. A rota SSR usa
getServerSupabase(), que cai para a anon key quando não há service-role (supabase.ts:11-15), então valia conferir sescoresé legível poranonantes de assumir. É:0001_coderacer_init.sql:64-68já temcreate policy "public read scores" … using (true)+grant select on public.scores to anon. Consultarscoresdireto funciona nos dois modos de deploy. Não abra migration nesta fatia.(b) Dedupe em TS tem de ser por
lower(name), não pornamecru — a view usadistinct on (lower(name))(0001:45). Sem isso,Caioecaioviram duas linhas no caminho filtrado e uma no não-filtrado. Desempate idêntico ao da view:wpm desc, created_at desc(0001:54) — é o que dá a estabilidade que a AC pede no empate.(c) Bug latente que a fatia vai expor (decida de propósito): hoje a view escolhe o PB global do nick e só depois o
.lte("wpm", MAX_PLAUSIBLE_WPM)é aplicado (supabase.ts:59). Quem tem um registro legado de 3596 é apagado do ranking inteiro em vez de aparecer com seu melhor score legítimo. No caminho filtrado (filtra antes, deduplica em TS) o bug não existe → o mesmo nick apareceria com filtro e sumiria sem. Recomendo unificar: os dois caminhos consultamscorese deduplicam em TS, e a view fica só como artefato histórico. Corrige o bug, mata a divergência e deixa um caminho de código. É mudança de comportamento no caminho sem filtro — declare no PR, não a esconda.(d) A folga do
limitprecisa de teto..order("wpm", desc).limit(limit × k)com umksolto é leitura ilimitada; e se um único nick tiver muitas linhas no topo, 25 depois do dedupe pode não fechar. Use um teto fixo (ex.: 500 linhas), deduplique, corte 25 — e aceite a lista curta no caso patológico em vez de paginar.Segurança/Anti-cheat: A superfície nova é a query string virando predicado de Postgres. Não escreva validação nova:
resolveLangeresolveDifficulty(room.ts:215-241) já retornamFieldResolutione são a allowlist que as rotas de sala usam — reuse. Divergência deliberada com a rota de criação: lá, valor inválido é 400; numa URL compartilhada o certo é ignorar o filtro e cair no default, nunca 400 (a AC já pede isso — mas note queresolveLangdevolve{ok:false}, então o call site é que decide cair no default).periodnão passa por allowlist nenhuma: mapeiehoje|semana|todospara umDatecalculado no servidor e nunca interpole a string do usuário numa data.O teto
.lte("wpm", MAX_PLAUSIBLE_WPM)tem de estar em todos os ramos — é o que segura o legado forjável enquanto a Anti-cheat: finish sem coerência temporal — leaderboard forjável em 1 request e corrida alheia encerrável #34 não fecha a porta de escrita. Umselectsem o.lteem um único caminho reabre o 3596 no ranking.UX/Game-feel: Chips como
<Link>que só trocam a query string mantêm a página 100% servidor — sem"use client", sem JS novo, e o estado do filtro fica compartilhável e no histórico do navegador, que é o comportamento certo para quem quer mandar "olha meu 1º lugar em sql/hard". Para teclado,<Link>já é focável: userole="group"+aria-current="page"no chip ativo em vez dearia-pressed(que é semântica de botão de alternância, e estes são navegação). Nada anima →prefers-reduced-motionnão entra aqui, e não é motivo para adicionar movimento.Um pedido concreto de rótulo: troque
hoje/semanapor24 h/7 dias. A Vercel roda em UTC e o público é UTC−3: "hoje" viraria ontem às 21h local, e o jogador veria seu score sumir sem explicação. Janela deslizante elimina o fuso do problema sem uma linha de código de timezone.Performance: Fora da área sagrada — outra rota, sem
textarea. Bundle inalterado (tudo servidor, e a página já édynamic = "force-dynamic", então cada combinação de filtro é renderizada por request, sem cache a invalidar). No banco: os índices existentes sãoscores (wpm desc)escores (lower(name))(0001:37-39) — não há índice emcreated_atnem em(language, difficulty), então o filtro varre. No volume atual é irrelevante e não é para abrir migration agora; anote como follow-up (índice aditivo é §7.2) para quandoscorescrescer.Produto: Prioridade P2 correta e a fatia é a certa: sem filtro, o ranking mede qual bucket o jogador escolheu, e a prova colhida em produção (mesmo jogador, mesma linguagem, 41 WPM de diferença) é boa demais para deixar parada. Atende exatamente a persona competitiva da spec §0.3 e o §III.8 ("o competitivo vem aqui medir-se"). Mantenha "ranqueado × casual" fora — depende de cruzar
matches.player_counte de uma regra de produto que ninguém decidiu; virar isso agora inchava a fatia e atrasa o ganho óbvio.Plano sugerido:
src/lib/leaderboard.ts(novo, puro e testável):export function bestPerName(rows: LeaderRow[]): LeaderRow[]— dedupe porname.toLowerCase(), desempatewpm desc, created_at desc, ordem de saída porwpm desc. É a única peça com lógica; teste-a isolada (empate de WPM → estável;Caio/caio→ 1 linha).src/lib/supabase.ts:getLeaderboard(limit = 25, filters?: { lang?: LangId; diff?: Difficulty; sinceISO?: string })passa a consultarscoressempre, com.lte("wpm", MAX_PLAUSIBLE_WPM)+.eq/.gtecondicionais,.order("wpm", {ascending:false}).limit(500), e devolvebestPerName(data).slice(0, limit).src/app/leaderboard/page.tsx:32-37: recebesearchParams, resolvelang/diffporresolveLang/resolveDifficulty(inválido → sem filtro), mapeiaperiod→sinceISO, e repassa. Chips =<Link href={...}>acima da tabela.metadata.alternates.canonicalé estático empage.tsx:15e aponta para/leaderboard— mantenha assim de propósito (as variantes filtradas não devem competir por indexação). Não troque porgenerateMetadatanesta fatia; se um dia quiser indexá-las, é issue própria — e conversa com a Practice/Fatia 4 — Polish/SEO: metadata indexável de /practice + sitemap #84.pnpm typecheck·pnpm build·node scripts/validate-persistence.mjs·vitest(com o teste novo debestPerName). Sem migration ⇒ sempnpm db:migrate.scores+ dedupe em TS) e que isso conserta o nick apagado por score legado — é a única mudança de comportamento observável.caioross commentedon Jul 28, 2026
Resolvida em #106 (non-draft, §7.2 quórum — toca
src/lib/supabase.ts).Segui o 🏛️ Parecer na íntegra: sem migration (
scoresjá é legível poranon), dedupe porlower(name)com desempatewpm desc, created_at desc, os dois caminhos unificados emscorese teto fixo de 500 linhas antes do dedupe. Rótulos de período viraram
24 h/7 dias/sempre(janela deslizante), como o parecer pediu, para não depender de fuso.Uma divergência consciente: usei
isValidLang/isValidDifficultyem vez deresolveLang/resolveDifficulty— osresolve*tratam campo ausente comofallback, o queaqui viraria filtro ativo em vez de "sem filtro". A allowlist consultada é a mesma.
O caminho sem filtro mudou de fonte (view →
scores+ dedupe em TS) e isso conserta o nickapagado do ranking inteiro por um score legado implausível — está declarado no corpo da PR.
Gate:
typecheck✅ ·build✅ ·vitest170/170 ✅ (27 novos) ·validate-persistence72/72 ✅ ·validate-metrics45/45 ✅ · lint N/A. SSR verificado sem Supabase (4 URLs, incl. paramsinválidos → DOM idêntico ao sem filtro); a metade de dados não é provável sem credenciais e está
coberta pelos testes de
bestPerName.fix(a11y): chip de linguagem não usa a cor de marca como cor de texto (…
caioross commentedon Aug 2, 2026
Evidência de produção (01/08) — a persona O Competitivo mediu o efeito já materializado no ranking real, não em teoria: o topo da tabela é 76 · 56 · 56 WPM, e as quatro melhores marcas do líder (76, 67, 64, 62) são todas
SQL · easy. Sem filtro por linguagem/dificuldade/período, um recorde de COBOL/Sênior e um de SQL/Júnior brigam na mesma coluna — o ranking mede escolha de bucket, não digitação. Post completo: Discussion #120.Estado (não muda nada aqui): a PR #106 segue DRAFT +
decisao-dono, travada em #107 — os filtros como implementados lá moviam o dedupe para o TS e amplificavam a #34.