feat(leaderboard): filtros por linguagem, dificuldade e período no Ranking Global - #106
feat(leaderboard): filtros por linguagem, dificuldade e período no Ranking Global#106caioross wants to merge 2 commits into
Conversation
…nking Global (#92) Sem filtro, o ranking mede qual bucket o jogador escolheu e não quem digita mais rápido: o mesmo jogador tinha 76 WPM em sql/easy e 35 em sql/hard, e era o 76 que ocupava o 1º lugar. Agora a página aceita `?lang=`, `?diff=` e `?period=` e mostra o recorde de cada jogador DENTRO do bucket. - `src/lib/leaderboard.ts` (novo): `bestPerName` reproduz em TS a regra da view (`lower(name)`, desempate `wpm desc, created_at desc`) — a view deduplica ANTES de qualquer filtro, então filtrar em cima dela apagaria da lista quem tem o PB global em outro bucket. Período é janela deslizante (24h/7d), não calendário, para não depender de fuso (Vercel em UTC, público UTC−3). - `getLeaderboard` passa a ler `scores` em TODOS os caminhos e deduplicar em TS. Isso também conserta o nick apagado do ranking inteiro por um score legado implausível (a view escolhia o PB global antes do teto de WPM). - A página lê `searchParams`, valida por `isValidLang`/`isValidDifficulty` (mesma allowlist das rotas de sala) e continua 100% servidor: os chips são `<Link>` que só trocam a query string. O teto `.lte("wpm", MAX_PLAUSIBLE_WPM)` continua aplicado em todos os ramos. Closes #92
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…#92) O quórum adversarial (lente de Domínio) vetou os chips de filtro: a cor de marca da linguagem pintava o texto do chip inativo em `text-[11px]` sobre `--bg-card`. São hex de terceiros, nunca validados contra o fundo do tema — 15 das 24 reprovam WCAG AA e 6 nem chegam a 3:1 (`lua #2c2d72` = 1.58:1, `elixir #4b275f` = 1.61:1, praticamente invisíveis). O cenário é o estado DEFAULT da página (sem filtro, 24 chips inativos), e a sigla é o único portador da informação — o jogador não conseguia ler qual linguagem estava escolhendo. Contraria a norma escrita do próprio produto: `docs/UI-AAA-OVERHAUL.md:108` regra (4) ("cor nunca é o único portador de informação") e §I.1.3 (≥ 4.5:1 sobre `--bg-card`). Agravante removido junto: o `style` inline vencia o `hover:text-text` da className, então os 24 chips de linguagem ficavam sem feedback de hover. Agora o chip usa o token do tema (`text-text-muted`, ~5:1); a identidade da linguagem continua no `icon` (a sigla) e no `aria-label`. Gate no worktree: typecheck ✅ · build ✅ (/leaderboard segue ƒ 1.63 kB) · vitest 170/170 ✅ · validate-persistence 72/72 ✅ · validate-metrics 45/45 ✅ Refs #92
⚖️ Quórum adversarial (HANDBOOK §7.2) · 2026-07-29 · head
|
| Lente | Veredito |
|---|---|
| AppSec (RLS, service_role, injeção, validação) | ✅ APROVA — sem vetor |
| Ofensiva (jogador trapaceiro) | ❌ VETA — src/lib/supabase.ts:90 |
| Domínio (corretude no contexto) | ❌ VETA — src/app/leaderboard/page.tsx:73 |
Antes de mais nada: o trabalho é bom. Módulo puro separado, 27 testes novos, a divergência
isValidLang vs resolveLang corretamente justificada, a mudança de fonte declarada em vez de
escondida e o .lte(MAX_PLAUSIBLE_WPM) mantido no builder base — a lente AppSec verificou os 8
ramos possíveis e o teto vale em todos. O bestPerName reproduz fielmente o distinct on da
view, e o alerta de comparar created_at como string não procede: dentro de uma resposta o
PostgREST usa formato uniforme, então a comparação é monotônica.
❌ Veto 1 (Ofensiva) — o teto de 500 linhas torna o ranking global apagável
src/lib/supabase.ts:90 + :95 — a PR troca "dedupe no Postgres sobre a tabela inteira" (view
distinct on (lower(name))) por "ler as 500 melhores linhas cruas → deduplicar em TS → cortar 25".
O dedupe passou a acontecer depois do teto de linhas, então quem controla as 500 primeiras
linhas controla a lista inteira.
O ataque, sem login e sem digitar nada (verifiquei cada passo em origin/main):
POST /api/roomscommaxPlayers: 30(ABSOLUTE_MAX_PLAYERS,room.ts:282).POST /api/rooms/CODE {action:"start"}→status:"racing".POST /api/rooms/CODE {action:"finish", results:[…30 linhas…]}comwpm: 350—finish
não exige liderança nem prova de corrida, esanitizeResults(room.ts:322) só descarta
wpm > MAX_PLAUSIBLE_WPM, então 350 passa ebuildScoreRowsgrava as 30 linhas.{action:"reset"}→ lobby; repete.
17 iterações ≈ 52 requests de curl = 510 linhas com wpm: 350. A leitura é
lte(350) → order wpm desc, created_at desc → limit 500: essas linhas empatam no teto e ganham
o desempate por created_at desc, ocupando a janela inteira. bestPerName colapsa todas em
uma — e /leaderboard passa a exibir um único jogador, permanentemente (as linhas ficam
em scores; só o dono limpando o banco reverte).
Por que é regressão desta PR e não problema pré-existente: na main a mesma munição rende ao
atacante exatamente 1 das 25 vagas — a view deduplica a tabela toda antes de qualquer
limit, então as outras 24 continuam com jogadores reais. A main resiste; esta PR não. É a
diferença entre "poluir o topo" e "apagar o ranking".
O comentário em supabase.ts:44-48 prevê o caso ("um punhado de nicks ocupando as 500 melhores
linhas… a lista sai curta") e o trata como custo aceitável. Da ótica ofensiva não é: é um botão de
apagar, barato e sem reversão.
A raiz não é sua — é a #34 (finish forjável em 1 request), já catalogada como P1. Esta PR
não abre o buraco; ela multiplica o estrago dele por 25. Como main = deploy, não dá para mergear
essa amplificação enquanto a #34 estiver aberta. Escalado em [Decisão] #107 com as opções.
✅ Veto 2 (Domínio) — contraste dos chips · reparei em c0f0b59
page.tsx:73 — style={!active && color ? { color } : undefined} pintava o texto do chip com o
hex de marca da linguagem, em text-[11px] sobre --bg-card. São cores de terceiros, nunca
validadas contra o fundo do tema: 15 das 24 reprovam AA e 6 não chegam a 3:1 — lua #2c2d72
= 1.58:1, elixir #4b275f = 1.61:1 (refiz a conta e confirmo). O cenário é o estado
default da página (sem filtro, 24 chips inativos) e a sigla é o único portador da informação:
o jogador não conseguia ler qual linguagem estava escolhendo. Contraria a norma escrita do
produto — docs/UI-AAA-OVERHAUL.md:108 regra (4) e §I.1.3.
Agravante removido junto: o style inline vencia o hover:text-text da className, deixando os 24
chips sem feedback de hover.
Reparo: o chip usa o token do tema (text-text-muted, ~5:1); a identidade da linguagem segue no
icon e no aria-label. Gate no worktree: typecheck ✅ · build ✅ (/leaderboard segue
ƒ 1.63 kB) · vitest 170/170 ✅ · validate-persistence 72/72 ✅ · validate-metrics 45/45 ✅
Não bloqueiam (para o follow-up)
- Sem índice em
created_atnem(language, difficulty): a superfície é finita (24×3×3 = 216
combinações allowlistadas), mas o payload por request subiu de ~25 para até 500 linhas numa
rotaforce-dynamic. Índice aditivo quandoscorescrescer, como o parecer já anotou. - Lacuna de teste:
leaderboard.test.tsusa só...Zemcreated_at, nunca a forma de produção
(+00:00). O comportamento está certo, o teste é que não cobre o formato real. - Ganho colateral que vale registrar: aplicar o
.lteantes do dedupe fecha um buraco da
main, onde uma linha implausível apagava a vítima do ranking inteiro ([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).
Não converto em Refs #92: a PR resolve a issue inteira; o que falta não é escopo dela.
Contexto
Hoje o Ranking Global ordena WPM bruto misturando linguagens e dificuldades no mesmo pote.
A prova colhida em produção pelo Curador: o mesmo jogador, na mesma linguagem, tinha 76 WPM
em
sql/easye 35 emsql/hard— e era o 76 que estava cravado em 🥇. Para ser nº1 não épreciso digitar mais rápido, basta escolher o bucket com o snippet mais curto.
Segui o 🏛️ Parecer do Conselho (28/07) na íntegra — incluindo as quatro correções que ele
acrescenta ao corpo da issue: sem migration, dedupe por
lower(name), unificação dos doiscaminhos e teto fixo de leitura. Uma divergência deliberada, explicada abaixo.
O que mudou e por quê
src/lib/leaderboard.ts(novo, puro e testável)bestPerName(rows)— uma linha porlower(name), desempatewpm desc, created_at desc.É exatamente a regra da view
leaderboard(0001_coderacer_init.sql:43-54), reproduzida emTS porque a view faz
distinct onantes de qualquer filtro: filtrar em cima dela faria ojogador cujo PB global é
sql/easysumir ao filtrar porsql/hard, em vez de aparecercom o melhor dele naquele bucket.
resolvePeriod/periodSinceISO— janela deslizante (24 h/7 dias/sempre),calculada no servidor a partir de
now. Nunca interpola a string do usuário numa data.filtersToHref/hasActiveFilter— a URL é o estado; valor default sai da query string,então cada estado tem uma URL só.
src/lib/supabase.tsgetLeaderboard(limit, filters?)consultascoresem todos os caminhos (com e semfiltro), aplica
.lte("wpm", MAX_PLAUSIBLE_WPM)+.eq/.gtecondicionais, lê no máximo500 linhas (teto fixo, não
limit × ksolto), deduplica combestPerNamee corta emlimit. Sem migration:scoresjá é legível poranon(
0001_coderacer_init.sql:64-68— policyusing (true)+grant select), então funcionanos dois modos de deploy. A view continua no banco como artefato histórico.
src/app/leaderboard/page.tsxsearchParams(?lang=,?diff=,?period=), valida porisValidLang/isValidDifficulty(src/lib/room.ts— a mesma allowlist das rotas de sala) e continua100% servidor: nenhum
"use client", nenhum JS novo. Os chips são<Link>que só trocam aquery string, então o filtro é compartilhável e entra no histórico do navegador.
com a rota de criação de sala (lá, inválido é 400), como o parecer pediu.
(senão o jogador ficaria preso no filtro vazio); o estado "nenhuma partida registrada"
original só aparece quando não há filtro.
Mudança de comportamento observável (não escondida)
O caminho sem filtro trocou de fonte: view
leaderboard→scores+ dedupe em TS.Isso conserta um bug lateral que o parecer identificou: a view escolhia o PB global do nick
e só depois o
.lte(MAX_PLAUSIBLE_WPM)era aplicado, então quem tivesse um registro legadoimplausível (o 3596 da #28) era apagado do ranking inteiro em vez de aparecer com seu melhor
score legítimo. Agora aparece. É a única diferença visível no caminho sem filtro.
Divergência do plano do parecer, consciente: ele sugeriu reusar
resolveLang/resolveDifficulty;usei
isValidLang/isValidDifficultydo mesmo módulo — osresolve*embutem semântica defallbackpara campo ausente ({ok:true, value:fallback}), que aqui viraria um filtro ativo emvez de "sem filtro". A allowlist consultada é a mesma; só o wrapper não serve a este call site.
Validação (resultado real)
lint= N/A (o repo não tem config de ESLint; a CI roda typecheck + build).Verificação de SSR além do gate — build servido em porta isolada, sem Supabase configurado
(as credenciais são só do dono, então a metade de dados não dá para provar aqui; ela está
coberta pelos testes unitários de
bestPerName):/leaderboard/leaderboard?lang=sql&diff=hard&period=24haria-current="page"(SQL · 🟣 Sênior · 24 h)/leaderboard?lang=naoexiste&diff=impossivel&period=ontem/leaderboard?lang[]=sql&period=7dOs
hrefrenderizados trocam exatamente uma dimensão por chip (?lang=sql&diff=easy&period=24h,?lang=sql&diff=hardpara "sempre",/leaderboardpara "limpar filtros"), e os três grupos saemcom
role="group"+aria-label.Riscos
bestPerNamereproduzir aregra da view e por teste unitário do dedupe/desempate.
bucket, a lista sai com menos de 25. Preferi lista curta a paginar nesta fatia.
created_atnem em(language, difficulty)(scoressó temwpm descelower(name)): o filtro varre. Irrelevante no volume atual — follow-up de índice aditivoquando
scorescrescer, como o parecer anotou. Nenhuma migration nesta PR.metadata.alternates.canonicalsegue estático em/leaderboardde propósito: as variantesfiltradas não devem competir por indexação.
Fora de escopo, de propósito: separar ranqueado de casual (
matches.player_count) — depende deuma regra de produto que ninguém decidiu.
Toca
src/lib/supabase.ts→ HANDBOOK §7.2.Solicito quórum (HANDBOOK §7)
Closes #92