Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 146 additions & 4 deletions src/app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ import { Crown, Home, Target, Trophy, Zap } from "lucide-react";
import { Logo } from "@/components/Logo";
import { MatrixRain } from "@/components/MatrixRain";
import { getLeaderboard, getRecentMatches, isSupabaseConfigured } from "@/lib/supabase";
import { langById } from "@/lib/languages";
import { DIFFICULTIES, LANGUAGES, langById } from "@/lib/languages";
import { isValidDifficulty, isValidLang } from "@/lib/room";
import {
PERIODS,
filtersToHref,
hasActiveFilter,
periodSinceISO,
resolvePeriod,
type LeaderboardFilters
} from "@/lib/leaderboard";

export const dynamic = "force-dynamic";

Expand All @@ -29,10 +38,73 @@ function timeAgo(iso: string): string {

const medal = (i: number) => (i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : `${i + 1}º`);

export default async function LeaderboardPage() {
/** Primeiro valor de um param repetido (`?lang=a&lang=b`) — o resto é ignorado. */
const firstParam = (v: string | string[] | undefined): string | undefined =>
Array.isArray(v) ? v[0] : v;

/**
* Um chip de filtro. É `<Link>` (navegação, não toggle), então o estado do
* filtro fica na URL — compartilhável e no histórico do navegador — e a página
* segue 100% servidor, sem JS novo. Ativo é marcado com `aria-current="page"`.
*
* A cor de marca da linguagem NÃO pinta o texto do chip: são hex de terceiros,
* nunca validados contra `--bg-card`, e 15 das 24 reprovam AA em `text-[11px]`
* (`lua #2c2d72` = 1.58:1, `elixir #4b275f` = 1.61:1 — ilegíveis). A sigla é o
* único portador da informação aqui, então ela usa o token do tema
* (`text-text-muted`, ~5:1) e a identidade da linguagem fica no `aria-label` —
* `docs/UI-AAA-OVERHAUL.md:108` regra (4) e §I.1.3.
*/
function Chip({
href,
active,
label,
children
}: {
href: string;
active: boolean;
label?: string;
children: React.ReactNode;
}) {
return (
<Link
href={href}
aria-label={label}
aria-current={active ? "page" : undefined}
className={`px-2 py-1 rounded-md border text-[11px] font-mono font-bold ${
active
? "border-neon-green/60 bg-neon-green/10 text-neon-green"
: "border-bg-line text-text-muted hover:text-text hover:border-text-dim"
}`}
>
{children}
</Link>
);
}

export default async function LeaderboardPage({
searchParams
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
// Fronteira: a query string vira predicado de Postgres, então só passa pela
// allowlist já usada pelas rotas de sala (`room.ts`). Valor inválido numa URL
// compartilhada NÃO é 400 — cai no default (sem filtro), que é o certo aqui.
const rawLang = firstParam(searchParams.lang);
const rawDiff = firstParam(searchParams.diff);
const filters: LeaderboardFilters = {
lang: isValidLang(rawLang) ? rawLang : undefined,
diff: isValidDifficulty(rawDiff) ? rawDiff : undefined,
period: resolvePeriod(firstParam(searchParams.period))
};
const filtered = hasActiveFilter(filters);

const configured = isSupabaseConfigured();
const [leaders, matches] = await Promise.all([
getLeaderboard(25),
getLeaderboard(25, {
lang: filters.lang,
diff: filters.diff,
sinceISO: periodSinceISO(filters.period, Date.now())
}),
getRecentMatches(12)
]);

Expand Down Expand Up @@ -75,7 +147,7 @@ export default async function LeaderboardPage() {
</div>
)}

{leaders.length === 0 ? (
{leaders.length === 0 && !filtered ? (
<div className="card p-10 text-center">
<div className="text-5xl mb-3">🏁</div>
<p className="text-text font-mono">Ainda não há partidas registradas.</p>
Expand All @@ -93,7 +165,76 @@ export default async function LeaderboardPage() {
<div className="px-4 py-3 border-b border-bg-line flex items-center gap-2">
<Crown className="size-4 text-neon-amber" />
<span className="label">// melhores WPM</span>
{filtered && (
<Link
href="/leaderboard"
className="ml-auto text-[11px] font-mono text-text-muted hover:text-neon-green"
>
limpar filtros ✕
</Link>
)}
</div>

{/* filtros — o ranking sem eles mede qual bucket o jogador
escolheu, não quem digita mais rápido (#92) */}
<div className="px-4 py-3 border-b border-bg-line space-y-2">
<div role="group" aria-label="Filtrar por linguagem" className="flex flex-wrap gap-1">
<Chip href={filtersToHref(filters, { lang: undefined })} active={!filters.lang}>
todas
</Chip>
{LANGUAGES.map(l => (
<Chip
key={l.id}
href={filtersToHref(filters, { lang: l.id })}
active={filters.lang === l.id}
label={l.label}
>
{l.icon}
</Chip>
))}
</div>
<div className="flex flex-wrap gap-3">
<div
role="group"
aria-label="Filtrar por dificuldade"
className="flex flex-wrap gap-1"
>
<Chip href={filtersToHref(filters, { diff: undefined })} active={!filters.diff}>
todas
</Chip>
{DIFFICULTIES.map(d => (
<Chip
key={d.id}
href={filtersToHref(filters, { diff: d.id })}
active={filters.diff === d.id}
>
{d.label}
</Chip>
))}
</div>
<div
role="group"
aria-label="Filtrar por período"
className="flex flex-wrap gap-1"
>
{PERIODS.map(p => (
<Chip
key={p.id}
href={filtersToHref(filters, { period: p.id })}
active={filters.period === p.id}
>
{p.label}
</Chip>
))}
</div>
</div>
</div>

{leaders.length === 0 ? (
<p className="px-4 py-10 text-center text-sm text-text-muted font-mono">
nenhum recorde neste filtro ainda — seja o primeiro.
</p>
) : (
<table className="w-full text-sm font-mono">
<thead>
<tr className="text-left text-text-muted text-[11px] uppercase tracking-wider border-b border-bg-line">
Expand Down Expand Up @@ -134,6 +275,7 @@ export default async function LeaderboardPage() {
})}
</tbody>
</table>
)}
</section>

{/* recent matches */}
Expand Down
141 changes: 141 additions & 0 deletions src/lib/leaderboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, it, expect } from "vitest";
import {
bestPerName,
filtersToHref,
hasActiveFilter,
periodSinceISO,
resolvePeriod,
type LeaderRow
} from "./leaderboard";

// `bestPerName` substitui a view `leaderboard` no caminho de leitura (#92): é
// ela que garante uma linha por jogador DEPOIS do filtro de bucket. Se ela
// divergir da regra da view (`distinct on (lower(name))`, desempate
// `wpm desc, created_at desc`), o mesmo nick aparece duas vezes com filtro e uma
// sem — exatamente o bug que a fatia existe para não criar.

const row = (over: Partial<LeaderRow> = {}): LeaderRow => ({
name: "caio",
wpm: 80,
accuracy: 97,
errors: 2,
language: "sql",
difficulty: "easy",
created_at: "2026-07-20T12:00:00.000Z",
...over
});

describe("bestPerName — uma linha por jogador", () => {
it("mantém o maior WPM do jogador e descarta os demais", () => {
const out = bestPerName([
row({ wpm: 35, difficulty: "hard" }),
row({ wpm: 76, difficulty: "easy" }),
row({ wpm: 50, difficulty: "medium" })
]);
expect(out).toHaveLength(1);
expect(out[0].wpm).toBe(76);
});

it("deduplica por nick case-insensitive, como o `lower(name)` da view", () => {
const out = bestPerName([
row({ name: "Caio", wpm: 60 }),
row({ name: "caio", wpm: 90 }),
row({ name: "CAIO", wpm: 70 })
]);
expect(out).toHaveLength(1);
expect(out[0].wpm).toBe(90);
});

it("no empate de WPM fica a linha mais recente (desempate estável)", () => {
const antiga = row({ wpm: 80, created_at: "2026-07-01T00:00:00.000Z" });
const nova = row({ wpm: 80, created_at: "2026-07-25T00:00:00.000Z" });
expect(bestPerName([antiga, nova])[0].created_at).toBe(nova.created_at);
// ordem de entrada invertida → mesmo vencedor (determinístico)
expect(bestPerName([nova, antiga])[0].created_at).toBe(nova.created_at);
});

it("ordena a saída por WPM desc, empate pelo mais recente", () => {
const out = bestPerName([
row({ name: "ana", wpm: 70, created_at: "2026-07-10T00:00:00.000Z" }),
row({ name: "bia", wpm: 90 }),
row({ name: "gil", wpm: 70, created_at: "2026-07-22T00:00:00.000Z" })
]);
expect(out.map(r => r.name)).toEqual(["bia", "gil", "ana"]);
});

it("jogadores diferentes não interferem entre si", () => {
const out = bestPerName([row({ name: "ana", wpm: 40 }), row({ name: "bia", wpm: 41 })]);
expect(out.map(r => `${r.name}:${r.wpm}`)).toEqual(["bia:41", "ana:40"]);
});

it("lista vazia → lista vazia (sem filtro que case, a página mostra o estado vazio)", () => {
expect(bestPerName([])).toEqual([]);
});

it("não muta o array recebido", () => {
const rows = [row({ name: "ana", wpm: 40 }), row({ name: "bia", wpm: 90 })];
const copy = [...rows];
bestPerName(rows);
expect(rows).toEqual(copy);
});
});

describe("resolvePeriod — allowlist do período", () => {
it.each(["24h", "7d"])("aceita %s", p => {
expect(resolvePeriod(p)).toBe(p);
});

it.each([undefined, null, "", "hoje", "semana", "'; drop table scores; --", 7, ["24h"]])(
"valor inválido (%p) cai em `todos`, nunca quebra a página",
v => {
expect(resolvePeriod(v)).toBe("todos");
}
);
});

describe("periodSinceISO — janela deslizante calculada no servidor", () => {
const now = Date.UTC(2026, 6, 28, 15, 0, 0); // 2026-07-28T15:00:00Z

it("24h volta exatamente um dia", () => {
expect(periodSinceISO("24h", now)).toBe("2026-07-27T15:00:00.000Z");
});

it("7d volta exatamente sete dias", () => {
expect(periodSinceISO("7d", now)).toBe("2026-07-21T15:00:00.000Z");
});

it("`todos` não gera predicado de tempo", () => {
expect(periodSinceISO("todos", now)).toBeUndefined();
});
});

describe("filtersToHref — a URL é o estado", () => {
it("sem filtro nenhum a URL fica limpa", () => {
expect(filtersToHref({ period: "todos" }, {})).toBe("/leaderboard");
});

it("troca uma dimensão preservando as outras", () => {
const href = filtersToHref({ lang: "sql", diff: "hard", period: "7d" }, { diff: "easy" });
expect(href).toBe("/leaderboard?lang=sql&diff=easy&period=7d");
});

it("limpar uma dimensão a remove da query string", () => {
expect(filtersToHref({ lang: "sql", diff: "hard", period: "todos" }, { lang: undefined })).toBe(
"/leaderboard?diff=hard"
);
});
});

describe("hasActiveFilter", () => {
it("default (sem lang/diff e período `todos`) não é filtro", () => {
expect(hasActiveFilter({ period: "todos" })).toBe(false);
});

it.each([
{ lang: "sql", period: "todos" },
{ diff: "hard", period: "todos" },
{ period: "24h" }
] as const)("qualquer dimensão preenchida conta como filtro (%p)", f => {
expect(hasActiveFilter(f)).toBe(true);
});
});
Loading
Loading