You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 chama getLeaderboard(25) sem nenhum parâmetro de filtro.
⚠️Armadilha principal:supabase/migrations/0001_coderacer_init.sql:43-54 define 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/easy simplesmente desaparece da lista ao filtrar por sql/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 (view leaderboard, comportamento preservado). Com filtro → consulta scores diretamente 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.
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 de supabase/migrations/0004_settings_allowlist.sql) antes de tocar no Postgres; valor inválido cai no default.
Acceptance criteria
/leaderboard sem query param se comporta exatamente como hoje (PB global por nick, teto de WPM aplicado).
/leaderboard?lang=sql&diff=hard mostra o ranking restrito ao bucket: um jogador com 76 em sql/easy e 35 em sql/hard aparece com 35 — e não some da lista.
Filtro de período hoje / semana / todos funciona sobre scores.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-motion respeitado.
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).
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.
Problema
O Ranking Global ordena por WPM bruto misturando linguagens e dificuldades diferentes no mesmo pote. Como o snippet
easyé curto e ohardé 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):VINNY O RAPIDOVINNY O RAPIDOMesmo jogador, mesma linguagem, 41 WPM de diferença só pela dificuldade — e é o
76que 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.supabase/migrations/0001_coderacer_init.sql:43-54define a view comoselect 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)).limit * k) antes de deduplicar, senão o corte de 25 linhas acontece antes do dedupe e a lista fica curta.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.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.hoje/semana/todosfunciona sobrescores.created_at..lte("wpm", MAX_PLAUSIBLE_WPM)continua aplicado em todos os caminhos, filtrados ou não.aria-pressed/role="tablist");prefers-reduced-motionrespeitado.?lang=naoexiste,?diff=impossivel) não quebra a página nem chega cru à query.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 cruzarscorescommatches.player_count; vira issue própria depois desta.