Skip to content

feat(leaderboard): filtros por linguagem, dificuldade e período no Ranking Global - #106

Draft
caioross wants to merge 2 commits into
mainfrom
auto/issue-92-leaderboard-filters
Draft

feat(leaderboard): filtros por linguagem, dificuldade e período no Ranking Global#106
caioross wants to merge 2 commits into
mainfrom
auto/issue-92-leaderboard-filters

Conversation

@caioross

Copy link
Copy Markdown
Owner

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/easy e 35 em sql/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 dois
caminhos 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 por lower(name), desempate wpm desc, created_at desc.
    É exatamente a regra da view leaderboard (0001_coderacer_init.sql:43-54), reproduzida em
    TS porque a view faz distinct on antes de qualquer filtro: filtrar em cima dela faria o
    jogador cujo PB global é sql/easy sumir ao filtrar por sql/hard, em vez de aparecer
    com 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.ts

  • getLeaderboard(limit, filters?) consulta scores em todos os caminhos (com e sem
    filtro), aplica .lte("wpm", MAX_PLAUSIBLE_WPM) + .eq/.gte condicionais, lê no máximo
    500 linhas (teto fixo, não limit × k solto), deduplica com bestPerName e corta em
    limit. Sem migration: scores já é legível por anon
    (0001_coderacer_init.sql:64-68 — policy using (true) + grant select), então funciona
    nos dois modos de deploy. A view continua no banco como artefato histórico.

src/app/leaderboard/page.tsx

  • searchParams (?lang=, ?diff=, ?period=), valida por isValidLang /
    isValidDifficulty (src/lib/room.ts — a mesma allowlist das rotas de sala) e continua
    100% servidor
    : nenhum "use client", nenhum JS novo. Os chips são <Link> que só trocam a
    query string, então o filtro é compartilhável e entra no histórico do navegador.
  • Valor inválido numa URL compartilhada não é 400 — cai no default. Divergência deliberada
    com a rota de criação de sala (lá, inválido é 400), como o parecer pediu.
  • Filtro sem resultado mostra "nenhum recorde neste filtro ainda" mantendo os chips na tela
    (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 leaderboardscores + 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 legado
implausí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/isValidDifficulty do mesmo módulo — os resolve* embutem semântica de
fallback para campo ausente ({ok:true, value:fallback}), que aqui viraria um filtro ativo em
vez de "sem filtro". A allowlist consultada é a mesma; só o wrapper não serve a este call site.

Validação (resultado real)

pnpm install --frozen-lockfile   ✅
pnpm typecheck                   ✅
pnpm build                       ✅  (/leaderboard segue ƒ dynamic, 1.63 kB / 132 kB First Load)
pnpm test                        ✅  170/170 (27 novos em leaderboard.test.ts)
node scripts/validate-persistence.mjs                        ✅ 72/72
node .claude/skills/cr-typing-engine/scripts/validate-metrics.mjs ✅ 45/45

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

URL HTTP resultado
/leaderboard 200 estado sem filtro intacto
/leaderboard?lang=sql&diff=hard&period=24h 200 3 chips com aria-current="page" (SQL · 🟣 Sênior · 24 h)
/leaderboard?lang=naoexiste&diff=impossivel&period=ontem 200 DOM visível idêntico ao sem filtro
/leaderboard?lang[]=sql&period=7d 200 param repetido/array não quebra

Os href renderizados trocam exatamente uma dimensão por chip (?lang=sql&diff=easy&period=24h,
?lang=sql&diff=hard para "sempre", /leaderboard para "limpar filtros"), e os três grupos saem
com role="group" + aria-label.

Riscos

  • Mudança de fonte no caminho sem filtro (acima). Mitigada por bestPerName reproduzir a
    regra da view e por teste unitário do dedupe/desempate.
  • Caso patológico do teto de 500: se poucos nicks ocuparem as 500 melhores linhas de um
    bucket, a lista sai com menos de 25. Preferi lista curta a paginar nesta fatia.
  • Sem índice em created_at nem em (language, difficulty) (scores só tem wpm desc e
    lower(name)): o filtro varre. Irrelevante no volume atual — follow-up de índice aditivo
    quando scores crescer, como o parecer anotou. Nenhuma migration nesta PR.
  • metadata.alternates.canonical segue estático em /leaderboard de propósito: as variantes
    filtradas não devem competir por indexação.

Fora de escopo, de propósito: separar ranqueado de casual (matches.player_count) — depende de
uma regra de produto que ninguém decidiu.

Toca src/lib/supabase.tsHANDBOOK §7.2.

Solicito quórum (HANDBOOK §7)

Closes #92

…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
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
code-racer Ready Ready Preview, Comment Jul 29, 2026 4:13am

…#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
@caioross

Copy link
Copy Markdown
Owner Author

⚖️ Quórum adversarial (HANDBOOK §7.2) · 2026-07-29 · head 76d59dd

Veredito: 2 vetos com vetor concreto → NÃO mergeada. Um deles eu reparei; o outro escala para o dono.

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

  1. POST /api/rooms com maxPlayers: 30 (ABSOLUTE_MAX_PLAYERS, room.ts:282).
  2. POST /api/rooms/CODE {action:"start"}status:"racing".
  3. POST /api/rooms/CODE {action:"finish", results:[…30 linhas…]} com wpm: 350finish
    não exige liderança nem prova de corrida, e sanitizeResults (room.ts:322) só descarta
    wpm > MAX_PLAUSIBLE_WPM, então 350 passa e buildScoreRows grava as 30 linhas.
  4. {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:73style={!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:1lua #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)

Não converto em Refs #92: a PR resolve a issue inteira; o que falta não é escopo dela.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

decisao-dono Aguarda decisão humana — agentes não tocam

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Leaderboard/Integridade: ranking sem filtros premia o bucket mais fácil — filtros por linguagem/dificuldade/período (§III.8)

1 participant