diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts index 417fbf6..4c17cb5 100644 --- a/apps/api/src/context.ts +++ b/apps/api/src/context.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { createDb, createLogger, createRedis, TRACE_ID_HEADER, type loadApiEnv } from '@meldrank/shared/server'; +import { DEFAULT_LEADERBOARD_ELIGIBILITY } from '@meldrank/rating'; +import { createDb, createLeaderboardStore, createLogger, createRedis, TRACE_ID_HEADER, type loadApiEnv } from '@meldrank/shared/server'; import { createClerkAuth } from './identity'; import { createCasualTableStore } from './lobby/store'; import { createTicketMinter } from './lobby/tickets'; @@ -49,8 +50,11 @@ export function createApiRuntime(env: ApiEnv): ApiRuntime { // through the Redis-cached resolve-or-create over the db. const auth = createClerkAuth({ secretKey: env.CLERK_SECRET_KEY, authorizedParties: [env.WEB_APP_ORIGIN] }); const players = createPlayerResolver({ store: createPlayerStore(db), redis }); + // The leaderboard read path over the shared db, gated by the ruled eligibility + // knobs (leaderboard design D1/D4 — the API is the constants' sole consumer). + const leaderboard = createLeaderboardStore(db, DEFAULT_LEADERBOARD_ELIGIBILITY); - return { deps: { variants: variantCatalog, store, spawn, tickets, auth, players, log }, db, redis }; + return { deps: { variants: variantCatalog, store, spawn, tickets, auth, players, leaderboard, log }, db, redis }; } /** diff --git a/apps/api/src/routers/api.test.ts b/apps/api/src/routers/api.test.ts index e5688d1..9a74a40 100644 --- a/apps/api/src/routers/api.test.ts +++ b/apps/api/src/routers/api.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { SINGLE_DECK_PARTNERS, type RoomSpawnRequest } from '@meldrank/shared'; -import { createLogger, verifySeatTicket } from '@meldrank/shared/server'; +import { SINGLE_DECK_PARTNERS, type LeaderboardStanding, type RoomSpawnRequest } from '@meldrank/shared'; +import { createLogger, LeaderboardCursorError, verifySeatTicket, type LeaderboardStore } from '@meldrank/shared/server'; import { ApiError, type ApiContext } from '../trpc'; import { createCasualTableStore } from '../lobby/store'; import { FakeLobbyRedis } from '../lobby/store-fake'; @@ -39,6 +39,36 @@ function fakeSpawn(): FakeSpawn { return spawn; } +/** An in-memory leaderboard store the tests parameterize per case. */ +interface FakeLeaderboard extends LeaderboardStore { + page: Awaited>; + standing: LeaderboardStanding | null; + /** When true, `listPage` raises the typed cursor failure. */ + badCursor: boolean; + readonly listCalls: Parameters[0][]; + readonly standingCalls: Parameters[0][]; +} + +function fakeLeaderboard(): FakeLeaderboard { + const store: FakeLeaderboard = { + page: { items: [], nextCursor: null }, + standing: null, + badCursor: false, + listCalls: [], + standingCalls: [], + listPage(input) { + store.listCalls.push(input); + if (store.badCursor) return Promise.reject(new LeaderboardCursorError()); + return Promise.resolve(store.page); + }, + getStanding(input) { + store.standingCalls.push(input); + return Promise.resolve(store.standing); + }, + }; + return store; +} + /** A shared test harness: one Redis/store/spawn, with per-player callers. */ function harness(spawnOverride?: SpawnClient) { const redis = new FakeLobbyRedis(); @@ -58,6 +88,7 @@ function harness(spawnOverride?: SpawnClient) { upsert: () => Promise.resolve('unused'), anonymize: () => Promise.resolve(), }; + const leaderboard = fakeLeaderboard(); const deps: Omit = { variants: variantCatalog, store, @@ -65,12 +96,14 @@ function harness(spawnOverride?: SpawnClient) { tickets, auth, players, + leaderboard, log, traceId: 'test-trace', }; return { store, spawn, + leaderboard, caller: (playerId: string | null) => createCaller({ ...deps, playerId }), }; } @@ -102,6 +135,59 @@ describe('account + variant reference procedures', () => { }); }); +describe('leaderboard', () => { + const ROW = { rank: 1, playerId: 'p7', displayName: 'Placed', avatar: null, rating: 1650, gamesPlayed: 12 }; + + it('list delegates to the store and passes the page through', async () => { + const h = harness(); + h.leaderboard.page = { items: [ROW], nextCursor: 'cursor-2' }; + const page = await h.caller(null).leaderboard.list({ variantId: PARTNERS, limit: 20 }); + expect(page).toEqual({ items: [ROW], nextCursor: 'cursor-2' }); + expect(h.leaderboard.listCalls[0]).toMatchObject({ variantId: PARTNERS, limit: 20 }); + }); + + it('list rejects an unknown variant with a typed not-found before touching the store', async () => { + const h = harness(); + await expect(h.caller(null).leaderboard.list({ variantId: 'nope' })).rejects.toMatchObject({ + apiErrorCode: 'not-found', + }); + expect(h.leaderboard.listCalls).toHaveLength(0); + }); + + it('list maps the typed cursor failure to a validation error', async () => { + const h = harness(); + h.leaderboard.badCursor = true; + await expect(h.caller(null).leaderboard.list({ variantId: PARTNERS, cursor: 'forged' })).rejects.toMatchObject({ + apiErrorCode: 'validation', + }); + }); + + it('myStanding rejects an unauthenticated caller with a typed unauthorized', async () => { + await expect(harness().caller(null).leaderboard.myStanding({ variantId: PARTNERS })).rejects.toMatchObject({ + apiErrorCode: 'unauthorized', + }); + }); + + it('myStanding queries the store for the caller and passes the standing through', async () => { + const h = harness(); + h.leaderboard.standing = { rating: 1600, ratingDeviation: 80, gamesPlayed: 15, eligible: true, rank: 3, gamesRemaining: 0 }; + const standing = await h.caller('p1').leaderboard.myStanding({ variantId: PARTNERS }); + expect(standing).toEqual(h.leaderboard.standing); + expect(h.leaderboard.standingCalls).toEqual([{ variantId: PARTNERS, playerId: 'p1' }]); + }); + + it('myStanding passes a null (never played ranked) standing through', async () => { + const h = harness(); + expect(await h.caller('p1').leaderboard.myStanding({ variantId: PARTNERS })).toBeNull(); + }); + + it('myStanding rejects an unknown variant with a typed not-found', async () => { + await expect(harness().caller('p1').leaderboard.myStanding({ variantId: 'nope' })).rejects.toMatchObject({ + apiErrorCode: 'not-found', + }); + }); +}); + describe('casual.createTable', () => { it('seats the creator, opens the table, and lists it', async () => { const h = harness(); diff --git a/apps/api/src/routers/index.ts b/apps/api/src/routers/index.ts index c88558b..e3e7e7a 100644 --- a/apps/api/src/routers/index.ts +++ b/apps/api/src/routers/index.ts @@ -2,6 +2,7 @@ import { healthy } from '@meldrank/shared'; import { createCallerFactory, publicProcedure, router } from '../trpc'; import { accountRouter } from './account'; import { casualRouter } from './casual'; +import { leaderboardRouter } from './leaderboard'; import { matchRouter } from './match'; import { variantRouter } from './variant'; @@ -17,6 +18,7 @@ export const appRouter = router({ account: accountRouter, variant: variantRouter, casual: casualRouter, + leaderboard: leaderboardRouter, match: matchRouter, }); diff --git a/apps/api/src/routers/leaderboard.ts b/apps/api/src/routers/leaderboard.ts new file mode 100644 index 0000000..0c56f61 --- /dev/null +++ b/apps/api/src/routers/leaderboard.ts @@ -0,0 +1,44 @@ +import { + LeaderboardListInputSchema, + LeaderboardListOutputSchema, + LeaderboardMyStandingInputSchema, + LeaderboardMyStandingOutputSchema, +} from '@meldrank/shared'; +import { LeaderboardCursorError } from '@meldrank/shared/server'; +import { apiError, protectedProcedure, publicProcedure, router } from '../trpc'; + +/** + * The public ranked-standings surface (capability `leaderboard-api`): the + * conservative-sorted, eligibility-gated board page and the signed-in viewer's + * own standing. Ranking semantics live in the injected {@link LeaderboardStore} + * (leaderboard design D1–D3); the router owns only the catalog check and the + * cursor-failure → `validation` mapping. + */ +export const leaderboardRouter = router({ + list: publicProcedure + .input(LeaderboardListInputSchema) + .output(LeaderboardListOutputSchema) + .query(async ({ ctx, input }) => { + if (ctx.variants.get(input.variantId) === null) { + throw apiError('not-found', `unknown variant: ${input.variantId}`); + } + try { + return await ctx.leaderboard.listPage(input); + } catch (error) { + if (error instanceof LeaderboardCursorError) { + throw apiError('validation', 'invalid leaderboard cursor'); + } + throw error; + } + }), + + myStanding: protectedProcedure + .input(LeaderboardMyStandingInputSchema) + .output(LeaderboardMyStandingOutputSchema) + .query(({ ctx, input }) => { + if (ctx.variants.get(input.variantId) === null) { + throw apiError('not-found', `unknown variant: ${input.variantId}`); + } + return ctx.leaderboard.getStanding({ variantId: input.variantId, playerId: ctx.playerId }); + }), +}); diff --git a/apps/api/src/trpc.ts b/apps/api/src/trpc.ts index 2a47f4e..97639b7 100644 --- a/apps/api/src/trpc.ts +++ b/apps/api/src/trpc.ts @@ -1,6 +1,6 @@ import { initTRPC, TRPCError } from '@trpc/server'; import type { ApiErrorCode } from '@meldrank/shared'; -import type { Logger } from '@meldrank/shared/server'; +import type { LeaderboardStore, Logger } from '@meldrank/shared/server'; import type { ClerkAuth } from './identity'; import type { CasualTableStore } from './lobby/store'; import type { TicketMinter } from './lobby/tickets'; @@ -26,6 +26,8 @@ export interface ApiDeps { readonly auth: ClerkAuth; /** Resolves the verified Clerk identity to the internal `players.id` (design D6). */ readonly players: PlayerResolver; + /** The leaderboard read path (capability `leaderboard-api`) — injected as an interface so tests fake it. */ + readonly leaderboard: LeaderboardStore; /** The service base logger (capability `structured-logging`, design D3); `buildContext` derives the per-request child. */ readonly log: Logger; } diff --git a/apps/web/_next-typesafe-url_.d.ts b/apps/web/_next-typesafe-url_.d.ts index f9af3c0..a2e5a30 100644 --- a/apps/web/_next-typesafe-url_.d.ts +++ b/apps/web/_next-typesafe-url_.d.ts @@ -14,6 +14,7 @@ declare module "@@@next-typesafe-url" { } interface StaticRouter { + "/leaderboard": StaticRoute; "/": StaticRoute; "/sign-in": StaticRoute; "/sign-up": StaticRoute; diff --git a/apps/web/app/leaderboard/page.tsx b/apps/web/app/leaderboard/page.tsx new file mode 100644 index 0000000..e63a0d3 --- /dev/null +++ b/apps/web/app/leaderboard/page.tsx @@ -0,0 +1,21 @@ +import { LeaderboardView } from '@/components/leaderboard/leaderboard-view'; +import { PageShell } from '@/components/ui/page-shell'; +import { SiteHeader } from '@/components/ui/site-header'; + +/** + * The `/leaderboard` route (capability `leaderboard-web`): a thin server shell + * — the shared header and page chrome — around the client-side + * {@link LeaderboardView}, which owns the board query, the own-standing strip, + * and pagination. A static route: no dynamic params or search params in v1 + * (the variant is fixed to Single-Deck Partners, design D7). + */ +export default function LeaderboardPage() { + return ( + <> + + + + + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 007021e..86e485f 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,23 +1,20 @@ -import { UserButton } from '@clerk/nextjs'; - import { CasualHall } from '@/components/hall/casual-hall'; import { PageShell } from '@/components/ui/page-shell'; +import { SiteHeader } from '@/components/ui/site-header'; /** * The landing route — the casual hall (capability `casual-hall-web`). The page is a - * thin shell: the sign-out affordance (Clerk's account menu) and the page chrome, - * with the hall itself (identity, Quick Play / Create Table, Rejoin, and the - * open-table browse list) owned by {@link CasualHall}. + * thin shell: the shared site header (home / Leaderboard / the Clerk user control) + * and the page chrome, with the hall itself (identity, Quick Play / Create Table, + * Rejoin, and the open-table browse list) owned by {@link CasualHall}. */ export default function Home() { return ( - - {/* Sign-out affordance: Clerk's account menu, which includes Sign out. */} -
- -
- - -
+ <> + + + + + ); } diff --git a/apps/web/components/leaderboard/leaderboard-view.test.tsx b/apps/web/components/leaderboard/leaderboard-view.test.tsx new file mode 100644 index 0000000..a65fa37 --- /dev/null +++ b/apps/web/components/leaderboard/leaderboard-view.test.tsx @@ -0,0 +1,187 @@ +import type { LeaderboardRow, LeaderboardStanding } from '@meldrank/shared'; +import type { ReactNode } from 'react'; + +import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LeaderboardView } from './leaderboard-view'; +import { StandingStrip } from './standing-strip'; +import { type Handlers, renderWithTRPC } from './test-harness'; + +/** + * State-matrix tests for the leaderboard surface (leaderboard-web spec): + * board order/empty/error/pagination through the real infinite-query call + * site, and the standing strip's placed / provisional-countdown / + * provisional-zero / never-played / signed-out states. Clerk is mocked to a + * switchable signed-in flag — the `Show when="signed-in"` gating is what + * keeps the authenticated standing query from ever firing signed out. + */ +const clerk = vi.hoisted(() => ({ signedIn: false })); + +vi.mock('@clerk/nextjs', () => ({ + Show: ({ children, fallback, when }: { children?: ReactNode; fallback?: ReactNode; when: string }) => + (when === 'signed-in') === clerk.signedIn ? <>{children} : <>{fallback ?? null}, + UserButton: () =>
, +})); + +const VARIANT_ID = 'single-deck-partners'; + +function makeRow(rank: number, overrides: Partial = {}): LeaderboardRow { + return { + avatar: null, + displayName: `Player ${rank}`, + gamesPlayed: 20, + playerId: `player-${rank}`, + rank, + rating: 1700 - rank * 10, + ...overrides, + }; +} + +function makeStanding(overrides: Partial = {}): LeaderboardStanding { + return { + eligible: true, + gamesPlayed: 20, + gamesRemaining: 0, + rank: 17, + rating: 1650, + ratingDeviation: 60, + ...overrides, + }; +} + +beforeEach(() => { + clerk.signedIn = false; +}); + +// No `globals: true` in the Vitest config, so RTL's auto-cleanup never +// registers — unmount explicitly or renders leak across tests. +afterEach(cleanup); + +describe('LeaderboardView board states', () => { + it('renders rows in API order with rank numbers and raw ratings', async () => { + const handlers: Handlers = { + 'leaderboard.list': () => ({ items: [makeRow(1, { rating: 1650 }), makeRow(2, { rating: 1700 })], nextCursor: null }), + }; + renderWithTRPC(, handlers); + + expect(await screen.findByText('Player 1')).toBeInTheDocument(); + const names = screen.getAllByText(/^Player \d$/).map((node) => node.textContent); + expect(names).toEqual(['Player 1', 'Player 2']); + // Raw ratings display as returned — the conservative sort key never renders. + expect(screen.getByText('1650')).toBeInTheDocument(); + expect(screen.getByText('1700')).toBeInTheDocument(); + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getByText('#2')).toBeInTheDocument(); + // Final page (null cursor) → no load-more affordance. + expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); + }); + + it('shows the explicit empty state when the board has no rows', async () => { + renderWithTRPC(, { 'leaderboard.list': () => ({ items: [], nextCursor: null }) }); + expect(await screen.findByText(/No placed players yet/)).toBeInTheDocument(); + }); + + it('shows an error state whose retry control refetches', async () => { + const handlers: Handlers = { + 'leaderboard.list': () => { + throw new Error('boom'); + }, + }; + renderWithTRPC(, handlers); + + expect(await screen.findByText('Could not load the leaderboard.')).toBeInTheDocument(); + + // The service recovers; retry must refetch and render the board. + handlers['leaderboard.list'] = () => ({ items: [makeRow(1)], nextCursor: null }); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(await screen.findByText('Player 1')).toBeInTheDocument(); + }); + + it('appends the next page on load-more and drops the affordance at the null cursor', async () => { + const listCalls: unknown[] = []; + const handlers: Handlers = { + 'leaderboard.list': (input) => { + listCalls.push(input); + const { cursor } = input as { cursor?: string }; + return cursor === 'cursor-2' + ? { items: [makeRow(3)], nextCursor: null } + : { items: [makeRow(1), makeRow(2)], nextCursor: 'cursor-2' }; + }, + }; + renderWithTRPC(, handlers); + + expect(await screen.findByText('Player 2')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Load more' })); + + // The next page's rows append after the current last row, ranks continuous. + expect(await screen.findByText('Player 3')).toBeInTheDocument(); + const names = screen.getAllByText(/^Player \d$/).map((node) => node.textContent); + expect(names).toEqual(['Player 1', 'Player 2', 'Player 3']); + expect(screen.getByText('#3')).toBeInTheDocument(); + expect(listCalls).toHaveLength(2); + // Null cursor → the affordance is gone. + expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); + }); +}); + +describe('StandingStrip states', () => { + it('shows rank and raw rating for a placed viewer', async () => { + clerk.signedIn = true; + renderWithTRPC(, { + 'leaderboard.myStanding': () => makeStanding({ eligible: true, rank: 17, rating: 1650 }), + }); + + expect(await screen.findByTestId('standing-strip')).toBeInTheDocument(); + expect(screen.getByText('#17')).toBeInTheDocument(); + expect(screen.getByText('1650')).toBeInTheDocument(); + expect(screen.queryByText('Provisional')).not.toBeInTheDocument(); + }); + + it('shows the Provisional badge with the play-N-more countdown', async () => { + clerk.signedIn = true; + renderWithTRPC(, { + 'leaderboard.myStanding': () => makeStanding({ eligible: false, gamesRemaining: 4, rank: null }), + }); + + expect(await screen.findByText('Provisional')).toBeInTheDocument(); + expect(screen.getByText(/Play 4 more to place/)).toBeInTheDocument(); + }); + + it('shows a generic keep-playing message when games are met but RD is above the gate', async () => { + clerk.signedIn = true; + renderWithTRPC(, { + 'leaderboard.myStanding': () => makeStanding({ eligible: false, gamesRemaining: 0, rank: null }), + }); + + expect(await screen.findByText('Provisional')).toBeInTheDocument(); + expect(screen.getByText('Keep playing ranked to place')).toBeInTheDocument(); + // Never "play 0 more". + expect(screen.queryByText(/0 more/)).not.toBeInTheDocument(); + }); + + it('prompts a viewer who has never played ranked', async () => { + clerk.signedIn = true; + renderWithTRPC(, { + 'leaderboard.myStanding': () => null, + }); + + expect(await screen.findByText('Play ranked to get placed on the board.')).toBeInTheDocument(); + }); + + it('renders nothing and never calls the authenticated procedure when signed out', async () => { + clerk.signedIn = false; + let standingCalls = 0; + const { container } = renderWithTRPC(, { + 'leaderboard.myStanding': () => { + standingCalls += 1; + return null; + }, + }); + + // Give any (wrongly) mounted query a tick to fire before asserting. + await Promise.resolve(); + expect(container).toBeEmptyDOMElement(); + expect(standingCalls).toBe(0); + }); +}); diff --git a/apps/web/components/leaderboard/leaderboard-view.tsx b/apps/web/components/leaderboard/leaderboard-view.tsx new file mode 100644 index 0000000..132c259 --- /dev/null +++ b/apps/web/components/leaderboard/leaderboard-view.tsx @@ -0,0 +1,102 @@ +'use client'; + +import type { LeaderboardRow } from '@meldrank/shared'; + +import { SINGLE_DECK_PARTNERS } from '@meldrank/shared'; +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { useTRPC } from '@/lib/trpc'; + +import { StandingStrip } from './standing-strip'; + +/** The one variant accruing ratings in v1 (design D7 — no variant selector yet). */ +const VARIANT_ID = SINGLE_DECK_PARTNERS.id; +const PAGE_SIZE = 20; + +/** + * The `/leaderboard` board surface (capability `leaderboard-web`): the + * signed-in viewer's standing strip, then the conservative-ordered board via + * an infinite query on `leaderboard.list` — rows append page by page and the + * load-more affordance disappears once the API returns a null cursor. + */ +export function LeaderboardView() { + const trpc = useTRPC(); + const board = useInfiniteQuery( + trpc.leaderboard.list.infiniteQueryOptions( + { limit: PAGE_SIZE, variantId: VARIANT_ID }, + { getNextPageParam: (page) => page.nextCursor }, + ), + ); + + const rows = board.data?.pages.flatMap((page) => page.items) ?? []; + + return ( +
+

Leaderboard

+

{SINGLE_DECK_PARTNERS.name}

+ + + + {board.isPending ? ( +

Loading the leaderboard…

+ ) : board.isError ? ( +
+

Could not load the leaderboard.

+ +
+ ) : rows.length === 0 ? ( +

No placed players yet — play ranked to be the first.

+ ) : ( + <> +
    + {rows.map((row) => ( +
  • + +
  • + ))} +
+ {board.hasNextPage && ( + + )} + + )} +
+ ); +} + +/** + * One placed player: rank, avatar/name, the **raw** rating (Rating & Ranking + * §7 — the conservative sort key is never displayed), and games played. The + * row links nowhere yet — profiles are slice A6. + */ +function BoardRow({ row }: { row: LeaderboardRow }) { + return ( + + +
+ #{row.rank} + {row.avatar !== null && ( + // eslint-disable-next-line @next/next/no-img-element -- Clerk-hosted avatar URLs are remote and tiny; next/image config is not warranted for them yet. + + )} + {row.displayName} +
+
+ {Math.round(row.rating)} + {row.gamesPlayed} games +
+
+
+ ); +} diff --git a/apps/web/components/leaderboard/standing-strip.tsx b/apps/web/components/leaderboard/standing-strip.tsx new file mode 100644 index 0000000..15f2e05 --- /dev/null +++ b/apps/web/components/leaderboard/standing-strip.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { Show } from '@clerk/nextjs'; +import { useQuery } from '@tanstack/react-query'; + +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { useTRPC } from '@/lib/trpc'; + +/** + * The signed-in viewer's own-standing strip (leaderboard design D6/D7), + * rendered above the board. Clerk's `Show when="signed-in"` gates the whole + * subtree, so a signed-out visitor never mounts the query component — the + * authenticated `leaderboard.myStanding` procedure is never called without a + * session. + */ +export function StandingStrip({ variantId }: { variantId: string }) { + return ( + + + + ); +} + +function StandingStripInner({ variantId }: { variantId: string }) { + const trpc = useTRPC(); + const standing = useQuery(trpc.leaderboard.myStanding.queryOptions({ variantId })); + + if (standing.isPending) { + return

Loading your standing…

; + } + + if (standing.isError) { + return

Could not load your standing.

; + } + + return ( + + + Your standing + + + + ); +} + +/** + * The standing state matrix (design D6): placed → rank + raw rating; + * Provisional with a countdown → "play N more to place"; games met but RD + * still above the gate (`gamesRemaining` 0) → a generic keep-playing message, + * never "play 0 more"; no rating row → the play-ranked prompt. + */ +function StandingSummary({ + standing, +}: { + standing: null | { eligible: boolean; gamesRemaining: number; rank: null | number; rating: number }; +}) { + if (standing === null) { + return Play ranked to get placed on the board.; + } + + if (standing.eligible && standing.rank !== null) { + return ( + + Rank #{standing.rank} ·{' '} + {Math.round(standing.rating)} + + ); + } + + return ( + + Provisional + {standing.gamesRemaining > 0 + ? `Play ${standing.gamesRemaining} more to place` + : 'Keep playing ranked to place'} + + ); +} diff --git a/apps/web/components/leaderboard/test-harness.tsx b/apps/web/components/leaderboard/test-harness.tsx new file mode 100644 index 0000000..2ad1db7 --- /dev/null +++ b/apps/web/components/leaderboard/test-harness.tsx @@ -0,0 +1,67 @@ +import type { AppRouter } from '@meldrank/api'; +import type { ReactNode } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, type RenderResult } from '@testing-library/react'; +import { createTRPCClient, httpLink } from '@trpc/client'; + +import { TRPCProvider } from '@/lib/trpc'; + +export type Handlers = Record; + +/** + * Component-test harness for tRPC-consuming leaderboard components: the real + * `TRPCProvider`/`QueryClient` wiring over an `httpLink` whose `fetch` is + * answered from an in-test handler map — so `queryOptions` / + * `infiniteQueryOptions` call sites are exercised exactly as in production, + * without a network. Handlers are looked up per call, so a test can swap one + * mid-flight (e.g. fail once, then succeed for the retry). + */ +export type ProcedureHandler = (input: unknown) => unknown; + +/** Render `ui` inside the tRPC + Query providers, answered by `handlers`. */ +export function renderWithTRPC(ui: ReactNode, handlers: Handlers): RenderResult { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const trpcClient = createTRPCClient({ + links: [httpLink({ fetch: testFetch(handlers), url: 'http://trpc.test' })], + }); + return render( + + + {ui} + + , + ); +} + +/** + * Answer a tRPC-over-HTTP query request from the handler map: the procedure + * path is the URL path, the input the `?input=` JSON — the wire shape + * `httpLink` produces for queries (the only operation type these components + * issue). A missing handler or a handler throw becomes a tRPC error envelope. + */ +function testFetch(handlers: Handlers): typeof fetch { + return async (target) => { + const url = new URL(typeof target === 'string' ? target : target instanceof URL ? target.href : target.url); + const path = decodeURIComponent(url.pathname.replace(/^\//, '')); + const raw = url.searchParams.get('input'); + const input: unknown = raw === null ? undefined : JSON.parse(raw); + try { + const handler = handlers[path]; + if (handler === undefined) throw new Error(`no test handler for ${path}`); + const data = await handler(input); + return Response.json({ result: { data } }); + } catch (error) { + return Response.json( + { + error: { + code: -32603, + data: { code: 'INTERNAL_SERVER_ERROR', httpStatus: 500, path }, + message: error instanceof Error ? error.message : String(error), + }, + }, + { status: 500 }, + ); + } + }; +} diff --git a/apps/web/components/ui/site-header.test.tsx b/apps/web/components/ui/site-header.test.tsx new file mode 100644 index 0000000..69ff548 --- /dev/null +++ b/apps/web/components/ui/site-header.test.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react'; + +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SiteHeader } from './site-header'; + +/** + * The shared header's contract (leaderboard-web spec "Shared site header"): + * the wordmark links home, the Leaderboard link targets `/leaderboard`, the + * user control follows the Clerk session (account menu signed in, a sign-in + * affordance signed out), and the component renders a single banner landmark. + */ +const clerk = vi.hoisted(() => ({ signedIn: false })); + +vi.mock('@clerk/nextjs', () => ({ + Show: ({ children, fallback, when }: { children?: ReactNode; fallback?: ReactNode; when: string }) => + (when === 'signed-in') === clerk.signedIn ? <>{children} : <>{fallback ?? null}, + UserButton: () =>
, +})); + +beforeEach(() => { + clerk.signedIn = false; +}); + +// No `globals: true` in the Vitest config, so RTL's auto-cleanup never +// registers — unmount explicitly or renders leak across tests. +afterEach(cleanup); + +describe('SiteHeader', () => { + it('renders exactly one banner with the wordmark home link and the Leaderboard link', () => { + render(); + + expect(screen.getAllByRole('banner')).toHaveLength(1); + expect(screen.getByRole('link', { name: 'MeldRank' })).toHaveAttribute('href', '/'); + expect(screen.getByRole('link', { name: 'Leaderboard' })).toHaveAttribute('href', '/leaderboard'); + }); + + it('offers a sign-in affordance when signed out', () => { + render(); + + expect(screen.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/sign-in'); + expect(screen.queryByTestId('user-button')).not.toBeInTheDocument(); + }); + + it('renders the Clerk user control when signed in', () => { + clerk.signedIn = true; + render(); + + expect(screen.getByTestId('user-button')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Sign in' })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/ui/site-header.tsx b/apps/web/components/ui/site-header.tsx new file mode 100644 index 0000000..365ad92 --- /dev/null +++ b/apps/web/components/ui/site-header.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { Show, UserButton } from '@clerk/nextjs'; +import { $path } from 'next-typesafe-url'; +import Link from 'next/link'; + +import { Button } from '@/components/ui/button'; + +/** + * The first shared nav element (leaderboard design D7): wordmark home link, + * the Leaderboard entry point, and the Clerk user control (a sign-in + * affordance when signed out). Adopted by the landing and leaderboard pages — + * deliberately not a full shell (no active-state framework, no mobile drawer); + * A5/A6 extend it. + */ +export function SiteHeader() { + return ( +
+ + {/* Signed in: Clerk's account menu (includes Sign out); signed out: the sign-in affordance. */} + } size="sm" variant="outline"> + Sign in + + } + when="signed-in" + > + + +
+ ); +} diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts index cd77d19..e6d7b09 100644 --- a/apps/web/e2e/smoke.spec.ts +++ b/apps/web/e2e/smoke.spec.ts @@ -88,6 +88,18 @@ test('signed-in happy path: hall → Quick Play → live table → full hand con }); }); +test('header navigates to the leaderboard, which reaches board-or-empty state', async ({ page }) => { + await page.goto('/'); + await page.getByRole('link', { name: 'Leaderboard' }).click(); + await page.waitForURL(/\/leaderboard$/, { timeout: SURFACE_TIMEOUT }); + await expect(page.getByRole('heading', { name: 'Leaderboard' })).toBeVisible({ timeout: SURFACE_TIMEOUT }); + // A settled board: either the explicit empty state or at least one placed + // row (rows carry a "N games" line). Loading/error states satisfy neither. + const settled = page.getByText(/No placed players yet/).or(page.getByText(/\d+ games/)); + await expect(settled.first()).toBeVisible({ timeout: SURFACE_TIMEOUT }); + await expect(page.getByText('Could not load the leaderboard.')).toBeHidden(); +}); + /** Assert none of the terminal error surfaces rendered (spec scenario 3). */ async function expectNoErrorState(page: Page): Promise { for (const text of ERROR_TEXTS) { diff --git a/drizzle/0003_magical_clea.sql b/drizzle/0003_magical_clea.sql new file mode 100644 index 0000000..bb91b51 --- /dev/null +++ b/drizzle/0003_magical_clea.sql @@ -0,0 +1 @@ +CREATE INDEX "player_ratings_variant_conservative_idx" ON "player_ratings" USING btree ("variant_id",("rating" - 2 * "rating_deviation") DESC,"player_id"); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..a4aa497 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1111 @@ +{ + "id": "ab75e2c6-7d54-4618-9631-b0b868682143", + "prevId": "431fee91-5fc8-4ef8-9662-ad5662d28ca4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.bot_profiles": { + "name": "bot_profiles", + "schema": "", + "columns": { + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "bot_profiles_player_id_players_id_fk": { + "name": "bot_profiles_player_id_players_id_fk", + "tableFrom": "bot_profiles", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.players": { + "name": "players", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "player_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "clerk_user_id": { + "name": "clerk_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "player_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "players_clerk_user_id_key": { + "name": "players_clerk_user_id_key", + "columns": [ + { + "expression": "clerk_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"players\".\"clerk_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "players_clerk_identity_check": { + "name": "players_clerk_identity_check", + "value": "(\"players\".\"type\" = 'human') = (\"players\".\"clerk_user_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.match_participants": { + "name": "match_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "seat_index": { + "name": "seat_index", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "team": { + "name": "team", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "participant_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "placement": { + "name": "placement", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "final_score": { + "name": "final_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_abandoner": { + "name": "is_abandoner", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "match_participants_match_id_idx": { + "name": "match_participants_match_id_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "match_participants_player_id_idx": { + "name": "match_participants_player_id_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "match_participants_match_id_seat_index_key": { + "name": "match_participants_match_id_seat_index_key", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seat_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "match_participants_match_id_matches_id_fk": { + "name": "match_participants_match_id_matches_id_fk", + "tableFrom": "match_participants", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "match_participants_player_id_players_id_fk": { + "name": "match_participants_player_id_players_id_fk", + "tableFrom": "match_participants", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.matches": { + "name": "matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "mode": { + "name": "mode", + "type": "match_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "match_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "resolution_reason": { + "name": "resolution_reason", + "type": "resolution_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "variant_id": { + "name": "variant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "variant_version": { + "name": "variant_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "variant_snapshot": { + "name": "variant_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "variant_hash": { + "name": "variant_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "matches_completed_at_idx": { + "name": "matches_completed_at_idx", + "columns": [ + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.match_hand_lines": { + "name": "match_hand_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_hand_id": { + "name": "match_hand_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "side": { + "name": "side", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "meld": { + "name": "meld", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "counters": { + "name": "counters", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cumulative": { + "name": "cumulative", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "match_hand_lines_match_hand_id_idx": { + "name": "match_hand_lines_match_hand_id_idx", + "columns": [ + { + "expression": "match_hand_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "match_hand_lines_match_hand_id_match_hands_id_fk": { + "name": "match_hand_lines_match_hand_id_match_hands_id_fk", + "tableFrom": "match_hand_lines", + "tableTo": "match_hands", + "columnsFrom": [ + "match_hand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.match_hands": { + "name": "match_hands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hand_number": { + "name": "hand_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "bidder_seat": { + "name": "bidder_seat", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "contract_value": { + "name": "contract_value", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "trump": { + "name": "trump", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "made": { + "name": "made", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "match_hands_match_id_idx": { + "name": "match_hands_match_id_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "match_hands_match_id_hand_number_key": { + "name": "match_hands_match_id_hand_number_key", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "hand_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "match_hands_match_id_matches_id_fk": { + "name": "match_hands_match_id_matches_id_fk", + "tableFrom": "match_hands", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.match_replays": { + "name": "match_replays", + "schema": "", + "columns": { + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "match_replays_match_id_matches_id_fk": { + "name": "match_replays_match_id_matches_id_fk", + "tableFrom": "match_replays", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.abandon_events": { + "name": "abandon_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "abandon_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "abandon_events_player_id_idx": { + "name": "abandon_events_player_id_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "abandon_events_match_id_idx": { + "name": "abandon_events_match_id_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "abandon_events_player_id_players_id_fk": { + "name": "abandon_events_player_id_players_id_fk", + "tableFrom": "abandon_events", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "abandon_events_match_id_matches_id_fk": { + "name": "abandon_events_match_id_matches_id_fk", + "tableFrom": "abandon_events", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_ratings": { + "name": "player_ratings", + "schema": "", + "columns": { + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "variant_id": { + "name": "variant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "rating_deviation": { + "name": "rating_deviation", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "volatility": { + "name": "volatility", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "games_played": { + "name": "games_played", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_rated_at": { + "name": "last_rated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "player_ratings_variant_conservative_idx": { + "name": "player_ratings_variant_conservative_idx", + "columns": [ + { + "expression": "variant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"rating\" - 2 * \"rating_deviation\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "player_ratings_player_id_players_id_fk": { + "name": "player_ratings_player_id_players_id_fk", + "tableFrom": "player_ratings", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_ratings_player_id_variant_id_pk": { + "name": "player_ratings_player_id_variant_id_pk", + "columns": [ + "player_id", + "variant_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rating_changes": { + "name": "rating_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "variant_id": { + "name": "variant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rating_before": { + "name": "rating_before", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "rating_after": { + "name": "rating_after", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "rd_before": { + "name": "rd_before", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "rd_after": { + "name": "rd_after", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "volatility_before": { + "name": "volatility_before", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "volatility_after": { + "name": "volatility_after", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "rating_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rating_changes_match_id_player_id_key": { + "name": "rating_changes_match_id_player_id_key", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rating_changes_player_id_variant_id_idx": { + "name": "rating_changes_player_id_variant_id_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "variant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rating_changes_match_id_idx": { + "name": "rating_changes_match_id_idx", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rating_changes_match_id_matches_id_fk": { + "name": "rating_changes_match_id_matches_id_fk", + "tableFrom": "rating_changes", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "rating_changes_player_id_players_id_fk": { + "name": "rating_changes_player_id_players_id_fk", + "tableFrom": "rating_changes", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.abandon_kind": { + "name": "abandon_kind", + "schema": "public", + "values": [ + "forfeit_abandon", + "timeout_abandon" + ] + }, + "public.match_mode": { + "name": "match_mode", + "schema": "public", + "values": [ + "ranked", + "casual" + ] + }, + "public.match_status": { + "name": "match_status", + "schema": "public", + "values": [ + "complete", + "aborted" + ] + }, + "public.participant_outcome": { + "name": "participant_outcome", + "schema": "public", + "values": [ + "win", + "loss", + "no_result" + ] + }, + "public.player_status": { + "name": "player_status", + "schema": "public", + "values": [ + "active", + "anonymized", + "banned" + ] + }, + "public.player_type": { + "name": "player_type", + "schema": "public", + "values": [ + "human", + "bot" + ] + }, + "public.rating_result": { + "name": "rating_result", + "schema": "public", + "values": [ + "normal_win", + "normal_loss", + "abandoner_full_loss", + "stranded_reduced_loss" + ] + }, + "public.resolution_reason": { + "name": "resolution_reason", + "schema": "public", + "values": [ + "played_out", + "forfeit_abandon", + "timeout_abandon", + "aborted" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index aa19137..bf84f28 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1783109157574, "tag": "0002_lame_falcon", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1783114477662, + "tag": "0003_magical_clea", + "breakpoints": true } ] } \ No newline at end of file diff --git a/openspec/changes/archive/2026-07-03-leaderboard/.openspec.yaml b/openspec/changes/archive/2026-07-03-leaderboard/.openspec.yaml new file mode 100644 index 0000000..43e65ca --- /dev/null +++ b/openspec/changes/archive/2026-07-03-leaderboard/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-03-leaderboard/design.md b/openspec/changes/archive/2026-07-03-leaderboard/design.md new file mode 100644 index 0000000..fa276d3 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-leaderboard/design.md @@ -0,0 +1,90 @@ +# Leaderboard — Design + +## Context + +A1/A2 landed the rating spine: `player_ratings` holds the current Glicko-2 state per (player × variant), `rating_changes` the audit trail, both written by the post-persist rating hook in `apps/api`. Rating & Ranking §7 fixes the display contract: sort on the conservative estimate `rating − 2·RD`, show the raw rating, gate the public board behind an eligibility threshold, label sub-threshold players Provisional. Rulings recorded 2026-07-03: gate = `RD ≤ 100 AND games_played ≥ 10`; signed-in viewers get an own-standing strip; navigation arrives via a first minimal shared header. + +Current-state constraints found in the repo: + +- tRPC procedures have no DB handle — `ApiDeps` (`apps/api/src/trpc.ts`) carries Redis/spawn/auth/resolver but no `DatabaseClient`; `createDb` is invoked in `createApiRuntime` (`apps/api/src/context.ts`) for other consumers only. +- `player_ratings` has no secondary index; a per-variant conservative-sort query would scan. +- Server-side DB read helpers conventionally live in `packages/shared/src/server/db/` (see `rating-store.ts`) with live-DB tests (`*.db.test.ts`, `describe.skipIf(!DATABASE_URL)`); router tests use `createCaller` over injected fakes (`apps/api/src/routers/api.test.ts`). +- Web is App Router + client-component tRPC hooks (`@trpc/tanstack-react-query`); shared API schemas live in `packages/shared/src/api/` with an established cursor-pagination convention (`CursorPaginationInputSchema`, `paginated()`). +- No shared nav exists; the landing page renders a floating Clerk `UserButton`. + +## Goals / Non-Goals + +**Goals** + +- Public, paginated, per-variant leaderboard query implementing §7 exactly (conservative sort, raw-rating display, eligibility gate, deterministic order). +- Own-standing query giving the signed-in viewer their rating, Provisional state, rank when placed, and a "play N more to place" hint. +- `/leaderboard` page + minimal shared header adopted by landing and leaderboard pages. +- Eligibility knobs as explicit config, joining the Rating §10 calibration knobs. + +**Non-Goals** + +- Profile links from board rows (A6), ranked-queue entry points (A5), seasons/`season_id`, inactivity RD growth, a variant selector UI (only Partners accrues ratings in v1), admin/ops views. + +## Decisions + +### D1 — Query lives in a shared store module, injected as an interface + +`packages/shared/src/server/db/leaderboard-store.ts` exports `createLeaderboardStore(db)` returning `{ listPage, getStanding }`. `ApiDeps` gains `leaderboard: LeaderboardStore`; `createApiRuntime` wires it from the `createDb` instance it already builds. + +- Why not `db: DatabaseClient` directly in `ApiDeps`: faking Drizzle call chains in the router test harness is brittle; an interface fake is one object literal. Matches the existing injected-resolver pattern (`players`). Future slices needing raw `db` can add it then. +- Why not queries inline in the router: loses the live-DB test seam the repo already uses (`rating-store.db.test.ts` precedent). + +### D2 — Ranking semantics + +- Sort key: `rating − 2 · rating_deviation` DESC; tiebreak `player_id` ASC (deterministic, stable across pages). +- Eligibility (SQL WHERE): `rating_deviation ≤ maxRatingDeviation AND games_played ≥ minGamesPlayed`, joined to `players` with `type = 'human' AND status = 'active'`. Bots can never have rating rows (Rating §8) but the human filter is kept as defense in depth; `banned`/`anonymized` drop off the board immediately. +- Rank: dense sequential position in the sorted eligible set (1-based). Pages carry ranks computed from the cursor (see D3); own-standing rank = `COUNT(eligible rows sorting strictly ahead) + 1`. + +### D3 — Keyset pagination, rank carried in the cursor + +Reuse the shared cursor convention (`{ items, nextCursor }`). The opaque cursor encodes `{ conservative, playerId, rank }` of the last row; the next page filters `(conservative, player_id)` after that key and numbers ranks from `rank + 1`. + +- Why keyset over offset: stable under concurrent rating updates (no row-shift duplicates/gaps mid-scroll); rank drift within an already-fetched page is acceptable for a ladder. +- Cursor is base64-encoded JSON, validated with Zod on decode; an invalid cursor is a `validation` error. + +### D4 — Eligibility knobs live in `@meldrank/rating` + +`DEFAULT_LEADERBOARD_ELIGIBILITY = Object.freeze({ maxRatingDeviation: 100, minGamesPlayed: 10 })` exported from `packages/rating` beside `DEFAULT_RATING_CONFIG`. + +- Why there: Rating §10 lists the eligibility threshold among the calibration knobs; A1 established that package as the knobs' home. The package stays pure (constants only); the API is the sole consumer — web receives derived fields (`eligible`, `gamesRemaining`) and never needs the constants. + +### D5 — Index to back the sort + +New expression index on `player_ratings`: `(variant_id, (rating - 2 * rating_deviation) DESC, player_id)` via Drizzle `sql` expression + generated migration. Eligible-set sizes will be small for a long time, but the index makes both the page scan and the own-rank `COUNT` cheap and is trivial now versus a retrofit. + +### D6 — Own-standing procedure shape + +`leaderboard.myStanding` is a `protectedProcedure` (viewer identity from `ctx.playerId`), input `{ variantId }`, output nullable: `null` when no `player_ratings` row exists (never played ranked). Otherwise `{ rating, ratingDeviation, gamesPlayed, eligible, rank | null, gamesRemaining }` where `gamesRemaining = max(0, minGamesPlayed − gamesPlayed)`. Edge: `gamesRemaining = 0` with `RD > maxRatingDeviation` stays Provisional — web shows a generic "keep playing to place" instead of a count. + +### D7 — Web composition + +- `app/leaderboard/page.tsx`: thin server component rendering a `'use client'` `LeaderboardView` (matches `casual-hall` pattern); `next-typesafe-url` route-type added per repo convention (no dynamic params/search params in v1 — variant fixed to `single-deck-partners`). +- Board list: `useInfiniteQuery` via `trpc.leaderboard.list.infiniteQueryOptions` with a "Load more" affordance; card/flex rows in the `open-table-list` style (no `` primitive exists); Tailwind v4 + existing `ui/` primitives (`PageShell`, `Badge`, `Card`). +- Own-standing strip: separate `myStanding` query rendered above the board for signed-in users only (Clerk `SignedIn`); Provisional badge + hint per D6. +- Shared header: new `components/ui/site-header.tsx` — wordmark linking home, Leaderboard link, `UserButton` (Clerk) with signed-out fallback to sign-in. Adopted by `app/page.tsx` (replacing the floating `UserButton`) and the leaderboard page. Deliberately not a full shell: no active-state framework, no mobile drawer; A5/A6 extend it. + +### D8 — API input validation + +`variantId` input validated as a non-empty string against the variant catalog in the router (`not-found` for unknown ids), defaulting to `single-deck-partners`. Unknown-but-valid variants (e.g. cutthroat) return an empty board / null standing naturally — no special casing. + +## Risks / Trade-offs + +- [Rank drift across pages while ratings move] → Keyset cursor guarantees no duplicate/missing rows; ranks on later pages are consistent with the cursor's snapshot ordering, not re-fetched pages. Acceptable for v1; a refresh re-anchors. +- [Expression-index syntax varies across Drizzle versions] → Verify the generated SQL in the migration; fallback is a plain `(variant_id)` index — correctness is unaffected, only scan cost at trivial current scale. +- [Two eligibility knobs create a "games met, RD not" state] → Explicitly specced (D6 edge) so the UI never shows "play 0 more to place". +- [Header touch on landing page could disturb the e2e smoke] → Smoke selectors reviewed/updated in the same change; header adds a nav landmark, doesn't remove existing elements. + +## Migration Plan + +1. Migration adds the index only (no table/column changes) — deploy-safe, no backfill, no ordering constraint vs app deploy. +2. API deploys before web (web calls the new procedures). Same Vercel pipeline; no Fly/env/secret changes. +3. Rollback: revert app code freely; the index is harmless to leave. + +## Open Questions + +- None blocking. Calibration values (100 / 10) are config knobs — retuning is a one-line change, no schema impact. diff --git a/openspec/changes/archive/2026-07-03-leaderboard/proposal.md b/openspec/changes/archive/2026-07-03-leaderboard/proposal.md new file mode 100644 index 0000000..2fef30c --- /dev/null +++ b/openspec/changes/archive/2026-07-03-leaderboard/proposal.md @@ -0,0 +1,35 @@ +# Leaderboard + +## Why + +The ranked ladder spine now computes and persists Glicko-2 ratings (A1 `glicko2-rating-core`, A2 `rating-persistence-and-application`), but nothing surfaces them — the ladder is invisible. The leaderboard is the public face of ranked play ("match ends, rating moves, leaderboard reflects it" — Rating & Ranking §5) and the next dependency-ordered slice (A3) in the v1 Completion plan: it must exist before the ranked queue (A5) and profiles (A6) give players reasons to climb it. + +## What Changes + +- New public tRPC `leaderboard` router in `apps/api`: per-variant, cursor-paginated board query sorted on the conservative estimate `rating − 2·rating_deviation`, displaying the raw rating (Rating & Ranking §7). +- Public-board eligibility gate: a player appears only with `rating_deviation ≤ 100` **and** `games_played ≥ 10` (both new config knobs — ruled 2026-07-03, resolving the calibration item left open for A3). Humans only; `banned`/`anonymized` players excluded. +- Own-standing query for the signed-in viewer: their per-variant rating, eligibility state, rank when placed, and games-remaining hint ("play N more to place") when Provisional. +- New `/leaderboard` page in `apps/web` rendering the board (rank, player, rating, games) with a Provisional own-standing strip, loading/error/empty states, and pagination. +- Minimal shared site header (logo/home, Leaderboard link, Clerk `UserButton`) introduced and applied to the landing and leaderboard pages — the first shared nav element (ruled 2026-07-03). +- Enabling plumbing: `db: DatabaseClient` added to the API tRPC context (procedures have no DB handle today), and a `(variant_id, …)` index on `player_ratings` to back the sort. + +Out of scope: profile links (A6 — board rows link nowhere yet), seasons (`season_id` stays null), ranked queue entry points (A5), inactivity RD growth (deferred per Rating §10). + +## Capabilities + +### New Capabilities + +- `leaderboard-api`: public tRPC surface for ranked standings — the paginated conservative-sorted board query with the eligibility gate, and the viewer's own-standing query with Provisional state. +- `leaderboard-web`: the `/leaderboard` page — board rendering, own-standing strip, and the shared site header that provides its navigation entry point. + +### Modified Capabilities + +_None — no existing spec's requirements change. The `player_ratings` index and the `db`-in-context wiring are implementation details of existing storage/API plumbing, not behavior changes._ + +## Impact + +- `apps/api`: new `routers/leaderboard.ts`, registered in `routers/index.ts`; `ApiDeps`/`ApiContext` gain `db` (wired in `context.ts`, faked in the router test harness). +- `packages/shared`: new leaderboard input/output Zod schemas in `src/api/` (reusing the cursor-pagination convention); new Drizzle index on `player_ratings` + migration. +- `packages/rating` (or shared config): new leaderboard-eligibility knobs (`maxRatingDeviation: 100`, `minGamesPlayed: 10`) alongside the existing calibration config. +- `apps/web`: new `app/leaderboard/` route (+ `route-type.ts`), board components, shared header component; landing page adopts the header. +- No new external dependencies; no changes to `apps/match` or `apps/bots`. diff --git a/openspec/changes/archive/2026-07-03-leaderboard/specs/leaderboard-api/spec.md b/openspec/changes/archive/2026-07-03-leaderboard/specs/leaderboard-api/spec.md new file mode 100644 index 0000000..cc3ae92 --- /dev/null +++ b/openspec/changes/archive/2026-07-03-leaderboard/specs/leaderboard-api/spec.md @@ -0,0 +1,104 @@ +# leaderboard-api + +## ADDED Requirements + +### Requirement: Public leaderboard query returns the conservative-sorted eligible board + +The API SHALL expose a public tRPC procedure `leaderboard.list` that, for a given variant, returns a cursor-paginated page of eligible players ordered by the conservative estimate `rating − 2 · rating_deviation` descending, tie-broken by `player_id` ascending. Each row SHALL carry a 1-based `rank`, the player's `playerId`, `displayName`, `avatar`, raw `rating` (the displayed number per Rating & Ranking §7), and `gamesPlayed`. The conservative sort key SHALL NOT be displayed as the player's rating. + +#### Scenario: Board is ordered by conservative estimate, not raw rating + +- **WHEN** eligible player A has rating 1700 with RD 90 (conservative 1520) and eligible player B has rating 1650 with RD 60 (conservative 1530) +- **THEN** B ranks ahead of A, while each row still displays its raw rating (1650 and 1700) + +#### Scenario: Deterministic tiebreak + +- **WHEN** two eligible players have identical conservative estimates +- **THEN** they are ordered by `player_id` ascending, and repeated queries return the same order + +#### Scenario: Pagination is stable via keyset cursor + +- **WHEN** a client requests the first page and then requests the next page using the returned `nextCursor` +- **THEN** the second page begins strictly after the last row of the first page (no duplicated or skipped players) with ranks continuing from the cursor position, and `nextCursor` is null on the final page + +#### Scenario: Invalid cursor is rejected + +- **WHEN** a client supplies a cursor that fails to decode or validate +- **THEN** the procedure fails with the `validation` error code + +#### Scenario: Unknown variant is rejected + +- **WHEN** a client supplies a `variantId` not present in the variant catalog +- **THEN** the procedure fails with the `not-found` error code + +#### Scenario: Variant with no rating rows yields an empty board + +- **WHEN** the board is queried for a catalog variant that has no `player_ratings` rows +- **THEN** the procedure returns an empty items list with a null `nextCursor` + +### Requirement: Public-board eligibility gate + +The board SHALL include only players whose `rating_deviation` is at or below the configured maximum (default 100) AND whose `games_played` is at or above the configured minimum (default 10), restricted to players with `type = 'human'` and `status = 'active'`. Both thresholds SHALL be defined as explicit configuration constants exported alongside the existing rating calibration knobs (Rating & Ranking §10). + +#### Scenario: Provisional player is excluded + +- **WHEN** a player has games_played 12 but rating_deviation 140 +- **THEN** they do not appear on the board + +#### Scenario: Low-RD player below the games floor is excluded + +- **WHEN** a player has rating_deviation 95 but games_played 8 +- **THEN** they do not appear on the board + +#### Scenario: Banned or anonymized players are excluded + +- **WHEN** a player whose ratings satisfy the gate has `status` of `banned` or `anonymized` +- **THEN** they do not appear on the board + +#### Scenario: Thresholds are configuration, not literals + +- **WHEN** the eligibility configuration constant is changed +- **THEN** the gate follows the new values with no other code change + +### Requirement: Own-standing query for the signed-in viewer + +The API SHALL expose an authenticated tRPC procedure `leaderboard.myStanding` that, for a given variant, returns the viewer's standing: `null` when the viewer has no rating row for the variant; otherwise their raw `rating`, `rating_deviation`, `gamesPlayed`, an `eligible` flag per the public-board gate, their 1-based `rank` when eligible (null otherwise), and `gamesRemaining = max(0, minGames − gamesPlayed)`. + +#### Scenario: Placed player sees their rank + +- **WHEN** an eligible viewer requests their standing +- **THEN** the response has `eligible: true` and a `rank` equal to one plus the count of eligible players sorting strictly ahead of them + +#### Scenario: Provisional player sees games remaining + +- **WHEN** a viewer with games_played 6 (below the minimum of 10) requests their standing +- **THEN** the response has `eligible: false`, `rank: null`, and `gamesRemaining: 4` + +#### Scenario: Games met but RD above the gate + +- **WHEN** a viewer has games_played at or above the minimum but rating_deviation above the maximum +- **THEN** the response has `eligible: false`, `rank: null`, and `gamesRemaining: 0` + +#### Scenario: Viewer has never played ranked + +- **WHEN** a viewer with no `player_ratings` row for the variant requests their standing +- **THEN** the procedure returns `null` + +#### Scenario: Unauthenticated caller is rejected + +- **WHEN** the procedure is called without an authenticated player context +- **THEN** it fails with the `unauthorized` error code + +### Requirement: Leaderboard read path is backed by an indexed store + +Leaderboard queries SHALL be implemented in a dedicated server store module in `packages/shared` (consistent with the existing rating-store pattern), injected into the API context as an interface, and `player_ratings` SHALL carry an index supporting the per-variant conservative-order scan. + +#### Scenario: Store is injectable for tests + +- **WHEN** router tests construct the tRPC caller +- **THEN** the leaderboard store can be replaced with an in-memory fake without a database connection + +#### Scenario: Index exists for the sort + +- **WHEN** the schema migration for this change is applied +- **THEN** `player_ratings` has an index covering `variant_id` and the conservative sort expression (or, at minimum, `variant_id`) diff --git a/openspec/changes/archive/2026-07-03-leaderboard/specs/leaderboard-web/spec.md b/openspec/changes/archive/2026-07-03-leaderboard/specs/leaderboard-web/spec.md new file mode 100644 index 0000000..c206b3b --- /dev/null +++ b/openspec/changes/archive/2026-07-03-leaderboard/specs/leaderboard-web/spec.md @@ -0,0 +1,79 @@ +# leaderboard-web + +## ADDED Requirements + +### Requirement: Leaderboard page renders the ranked board + +The web app SHALL serve a `/leaderboard` page rendering the Single-Deck Partners board from `leaderboard.list`: one row per eligible player showing rank, display name (with avatar when present), raw rating, and games played, in the order returned by the API. The page SHALL show a loading state while fetching, an error state with a retry affordance on failure, and an explicit empty state ("no placed players yet") when the board has no rows. + +#### Scenario: Board renders rows in API order + +- **WHEN** the board query returns players ranked 1..N +- **THEN** the page lists them in that order with rank numbers and raw ratings as returned + +#### Scenario: Empty board + +- **WHEN** the board query returns no items +- **THEN** the page shows the empty state instead of a blank list + +#### Scenario: Query failure + +- **WHEN** the board query fails +- **THEN** the page shows an error state with a retry control that refetches + +### Requirement: Board pagination + +The page SHALL load the board incrementally: an initial page, then a "load more" affordance that fetches the next page via the returned cursor and appends rows. The affordance SHALL disappear (or disable) when no further pages exist. + +#### Scenario: Loading the next page appends rows + +- **WHEN** the user activates "load more" and a next page exists +- **THEN** the next page's rows are appended after the current last row with continuous rank numbering + +#### Scenario: Final page reached + +- **WHEN** the API returns a null cursor +- **THEN** no further "load more" affordance is offered + +### Requirement: Own-standing strip + +For a signed-in viewer, the page SHALL render a personal standing strip above the board driven by `leaderboard.myStanding`: when eligible, their rank and raw rating; when Provisional with games remaining, a Provisional badge and "play N more to place" using `gamesRemaining`; when Provisional with `gamesRemaining` of 0 (RD still above the gate), a Provisional badge with a generic keep-playing message and no count; when they have never played ranked (null standing), a prompt to play ranked. Signed-out visitors SHALL see the public board without a standing strip. + +#### Scenario: Placed viewer + +- **WHEN** the signed-in viewer's standing is eligible with rank 17 +- **THEN** the strip shows rank 17 and their raw rating + +#### Scenario: Provisional viewer with a countdown + +- **WHEN** the standing has `eligible: false` and `gamesRemaining: 4` +- **THEN** the strip shows a Provisional badge and "play 4 more to place" phrasing + +#### Scenario: Provisional viewer without a countdown + +- **WHEN** the standing has `eligible: false` and `gamesRemaining: 0` +- **THEN** the strip shows a Provisional badge with a keep-playing message and never shows "play 0 more" + +#### Scenario: Signed-out visitor + +- **WHEN** no user is signed in +- **THEN** the board renders without a standing strip and without calling the authenticated procedure + +### Requirement: Shared site header provides the leaderboard entry point + +The web app SHALL introduce a minimal shared header component — wordmark/home link, a Leaderboard link, and the Clerk user control (sign-in affordance when signed out) — rendered on the landing page and the leaderboard page. The landing page's existing floating user control SHALL be replaced by the header. + +#### Scenario: Navigating to the leaderboard + +- **WHEN** a visitor on the landing page activates the header's Leaderboard link +- **THEN** they land on `/leaderboard` + +#### Scenario: Returning home + +- **WHEN** a visitor on the leaderboard page activates the wordmark/home link +- **THEN** they land on the landing page + +#### Scenario: Header on both pages + +- **WHEN** either the landing page or the leaderboard page renders +- **THEN** the shared header is present exactly once, and the landing page no longer renders the old floating user control diff --git a/openspec/changes/archive/2026-07-03-leaderboard/tasks.md b/openspec/changes/archive/2026-07-03-leaderboard/tasks.md new file mode 100644 index 0000000..5cbcb3c --- /dev/null +++ b/openspec/changes/archive/2026-07-03-leaderboard/tasks.md @@ -0,0 +1,31 @@ +# Leaderboard — Tasks + +## 1. Config & schema + +- [x] 1.1 Export `DEFAULT_LEADERBOARD_ELIGIBILITY` (`maxRatingDeviation: 100`, `minGamesPlayed: 10`, frozen) with its type from `packages/rating`, alongside `DEFAULT_RATING_CONFIG`; unit-test the values and immutability +- [x] 1.2 Add the `player_ratings` index for the per-variant conservative scan (`variant_id` + `rating - 2 * rating_deviation` DESC + `player_id`, per design D5) in `packages/shared/src/server/db/schema/ratings.ts`; generate the Drizzle migration and verify the emitted SQL (fall back to a plain `variant_id` index if the expression form misbehaves) +- [x] 1.3 Add leaderboard API schemas to `packages/shared/src/api/`: `LeaderboardListInputSchema` (variantId + shared cursor pagination), `LeaderboardListOutputSchema` (`paginated()` of board-row schema: rank, playerId, displayName, avatar, rating, gamesPlayed), `LeaderboardMyStandingInputSchema` / `...OutputSchema` (nullable standing per design D6); export via the api barrel + +## 2. Store (packages/shared) + +- [x] 2.1 Create `packages/shared/src/server/db/leaderboard-store.ts`: `LeaderboardStore` interface + `createLeaderboardStore(db)` with `listPage` (eligibility gate, human+active join, conservative order with playerId tiebreak, keyset cursor in/out with rank continuation) and `getStanding` (row lookup, eligible flag, rank via count-strictly-ahead, gamesRemaining); base64+Zod cursor codec with a typed decode failure +- [x] 2.2 Live-DB tests `leaderboard-store.db.test.ts` (skipIf no `DATABASE_URL`, mirroring `rating-store.db.test.ts` setup/cleanup): ordering vs raw rating, tiebreak determinism, both gate thresholds, banned/anonymized exclusion, two-page keyset walk (no dup/skip, rank continuity, null final cursor), standing for placed / provisional-games / provisional-RD / never-played players + +## 3. API router (apps/api) + +- [x] 3.1 Add `leaderboard: LeaderboardStore` to `ApiDeps` in `apps/api/src/trpc.ts` and wire `createLeaderboardStore(db)` in `createApiRuntime` (`apps/api/src/context.ts`) +- [x] 3.2 Create `apps/api/src/routers/leaderboard.ts`: `list` as `publicProcedure` (catalog check → `not-found`; invalid cursor → `validation`) and `myStanding` as `protectedProcedure`, both delegating to the store; register in `routers/index.ts` +- [x] 3.3 Router tests in the `api.test.ts` harness with an in-memory `LeaderboardStore` fake: happy paths, unknown variant, bad cursor, unauthorized `myStanding`, null standing passthrough + +## 4. Web (apps/web) + +- [x] 4.1 Create `components/ui/site-header.tsx` (wordmark home link, Leaderboard link, Clerk user control with signed-out sign-in affordance); adopt on `app/page.tsx`, removing the floating `UserButton`; component test for links + single render +- [x] 4.2 Create `app/leaderboard/page.tsx` (server shell + route-type per repo convention) rendering a `'use client'` `LeaderboardView`: infinite query on `leaderboard.list`, board rows (rank, avatar/name, raw rating, games) in the open-table-list row style, loading/error-with-retry/empty states, load-more per null-cursor contract +- [x] 4.3 Own-standing strip component (Clerk `SignedIn`-gated `myStanding` query): placed rank+rating, Provisional badge with "play N more to place", the gamesRemaining-0 keep-playing variant, never-played prompt; never renders for signed-out visitors +- [x] 4.4 Component tests (Vitest + Testing Library harness) for `LeaderboardView` and the standing strip covering the state matrix in the leaderboard-web spec scenarios + +## 5. Verification & wrap-up + +- [x] 5.1 Extend the Playwright smoke (`apps/web/e2e/`) minimally: header navigates landing → `/leaderboard`, page reaches board-or-empty state; fix any landing-page selector drift from the header adoption +- [x] 5.2 Apply the migration to the dev database and exercise `/leaderboard` against the live stack (run-meldrank skill) with seeded rating rows spanning eligible/provisional players +- [x] 5.3 Run validation (lint, typecheck, tests via the validate agent) and update the Linear v1 Completion plan A3 row status diff --git a/openspec/specs/leaderboard-api/spec.md b/openspec/specs/leaderboard-api/spec.md new file mode 100644 index 0000000..0b1e30d --- /dev/null +++ b/openspec/specs/leaderboard-api/spec.md @@ -0,0 +1,108 @@ +# leaderboard-api Specification + +## Purpose + +Public tRPC surface for ranked standings: a per-variant, cursor-paginated board query sorted on the conservative estimate (`rating − 2·rating_deviation`) with a public-board eligibility gate, and an authenticated own-standing query for the signed-in viewer. + +## Requirements + +### Requirement: Public leaderboard query returns the conservative-sorted eligible board + +The API SHALL expose a public tRPC procedure `leaderboard.list` that, for a given variant, returns a cursor-paginated page of eligible players ordered by the conservative estimate `rating − 2 · rating_deviation` descending, tie-broken by `player_id` ascending. Each row SHALL carry a 1-based `rank`, the player's `playerId`, `displayName`, `avatar`, raw `rating` (the displayed number per Rating & Ranking §7), and `gamesPlayed`. The conservative sort key SHALL NOT be displayed as the player's rating. + +#### Scenario: Board is ordered by conservative estimate, not raw rating + +- **WHEN** eligible player A has rating 1700 with RD 90 (conservative 1520) and eligible player B has rating 1650 with RD 60 (conservative 1530) +- **THEN** B ranks ahead of A, while each row still displays its raw rating (1650 and 1700) + +#### Scenario: Deterministic tiebreak + +- **WHEN** two eligible players have identical conservative estimates +- **THEN** they are ordered by `player_id` ascending, and repeated queries return the same order + +#### Scenario: Pagination is stable via keyset cursor + +- **WHEN** a client requests the first page and then requests the next page using the returned `nextCursor` +- **THEN** the second page begins strictly after the last row of the first page (no duplicated or skipped players) with ranks continuing from the cursor position, and `nextCursor` is null on the final page + +#### Scenario: Invalid cursor is rejected + +- **WHEN** a client supplies a cursor that fails to decode or validate +- **THEN** the procedure fails with the `validation` error code + +#### Scenario: Unknown variant is rejected + +- **WHEN** a client supplies a `variantId` not present in the variant catalog +- **THEN** the procedure fails with the `not-found` error code + +#### Scenario: Variant with no rating rows yields an empty board + +- **WHEN** the board is queried for a catalog variant that has no `player_ratings` rows +- **THEN** the procedure returns an empty items list with a null `nextCursor` + +### Requirement: Public-board eligibility gate + +The board SHALL include only players whose `rating_deviation` is at or below the configured maximum (default 100) AND whose `games_played` is at or above the configured minimum (default 10), restricted to players with `type = 'human'` and `status = 'active'`. Both thresholds SHALL be defined as explicit configuration constants exported alongside the existing rating calibration knobs (Rating & Ranking §10). + +#### Scenario: Provisional player is excluded + +- **WHEN** a player has games_played 12 but rating_deviation 140 +- **THEN** they do not appear on the board + +#### Scenario: Low-RD player below the games floor is excluded + +- **WHEN** a player has rating_deviation 95 but games_played 8 +- **THEN** they do not appear on the board + +#### Scenario: Banned or anonymized players are excluded + +- **WHEN** a player whose ratings satisfy the gate has `status` of `banned` or `anonymized` +- **THEN** they do not appear on the board + +#### Scenario: Thresholds are configuration, not literals + +- **WHEN** the eligibility configuration constant is changed +- **THEN** the gate follows the new values with no other code change + +### Requirement: Own-standing query for the signed-in viewer + +The API SHALL expose an authenticated tRPC procedure `leaderboard.myStanding` that, for a given variant, returns the viewer's standing: `null` when the viewer has no rating row for the variant; otherwise their raw `rating`, `rating_deviation`, `gamesPlayed`, an `eligible` flag per the public-board gate, their 1-based `rank` when eligible (null otherwise), and `gamesRemaining = max(0, minGames − gamesPlayed)`. + +#### Scenario: Placed player sees their rank + +- **WHEN** an eligible viewer requests their standing +- **THEN** the response has `eligible: true` and a `rank` equal to one plus the count of eligible players sorting strictly ahead of them + +#### Scenario: Provisional player sees games remaining + +- **WHEN** a viewer with games_played 6 (below the minimum of 10) requests their standing +- **THEN** the response has `eligible: false`, `rank: null`, and `gamesRemaining: 4` + +#### Scenario: Games met but RD above the gate + +- **WHEN** a viewer has games_played at or above the minimum but rating_deviation above the maximum +- **THEN** the response has `eligible: false`, `rank: null`, and `gamesRemaining: 0` + +#### Scenario: Viewer has never played ranked + +- **WHEN** a viewer with no `player_ratings` row for the variant requests their standing +- **THEN** the procedure returns `null` + +#### Scenario: Unauthenticated caller is rejected + +- **WHEN** the procedure is called without an authenticated player context +- **THEN** it fails with the `unauthorized` error code + +### Requirement: Leaderboard read path is backed by an indexed store + +Leaderboard queries SHALL be implemented in a dedicated server store module in `packages/shared` (consistent with the existing rating-store pattern), injected into the API context as an interface, and `player_ratings` SHALL carry an index supporting the per-variant conservative-order scan. + +#### Scenario: Store is injectable for tests + +- **WHEN** router tests construct the tRPC caller +- **THEN** the leaderboard store can be replaced with an in-memory fake without a database connection + +#### Scenario: Index exists for the sort + +- **WHEN** the schema migration for this change is applied +- **THEN** `player_ratings` has an index covering `variant_id` and the conservative sort expression (or, at minimum, `variant_id`) diff --git a/openspec/specs/leaderboard-web/spec.md b/openspec/specs/leaderboard-web/spec.md new file mode 100644 index 0000000..6beef1e --- /dev/null +++ b/openspec/specs/leaderboard-web/spec.md @@ -0,0 +1,83 @@ +# leaderboard-web Specification + +## Purpose + +The `/leaderboard` page: board rendering with pagination, a Provisional-aware own-standing strip for signed-in viewers, and the shared site header that provides its navigation entry point. + +## Requirements + +### Requirement: Leaderboard page renders the ranked board + +The web app SHALL serve a `/leaderboard` page rendering the Single-Deck Partners board from `leaderboard.list`: one row per eligible player showing rank, display name (with avatar when present), raw rating, and games played, in the order returned by the API. The page SHALL show a loading state while fetching, an error state with a retry affordance on failure, and an explicit empty state ("no placed players yet") when the board has no rows. + +#### Scenario: Board renders rows in API order + +- **WHEN** the board query returns players ranked 1..N +- **THEN** the page lists them in that order with rank numbers and raw ratings as returned + +#### Scenario: Empty board + +- **WHEN** the board query returns no items +- **THEN** the page shows the empty state instead of a blank list + +#### Scenario: Query failure + +- **WHEN** the board query fails +- **THEN** the page shows an error state with a retry control that refetches + +### Requirement: Board pagination + +The page SHALL load the board incrementally: an initial page, then a "load more" affordance that fetches the next page via the returned cursor and appends rows. The affordance SHALL disappear (or disable) when no further pages exist. + +#### Scenario: Loading the next page appends rows + +- **WHEN** the user activates "load more" and a next page exists +- **THEN** the next page's rows are appended after the current last row with continuous rank numbering + +#### Scenario: Final page reached + +- **WHEN** the API returns a null cursor +- **THEN** no further "load more" affordance is offered + +### Requirement: Own-standing strip + +For a signed-in viewer, the page SHALL render a personal standing strip above the board driven by `leaderboard.myStanding`: when eligible, their rank and raw rating; when Provisional with games remaining, a Provisional badge and "play N more to place" using `gamesRemaining`; when Provisional with `gamesRemaining` of 0 (RD still above the gate), a Provisional badge with a generic keep-playing message and no count; when they have never played ranked (null standing), a prompt to play ranked. Signed-out visitors SHALL see the public board without a standing strip. + +#### Scenario: Placed viewer + +- **WHEN** the signed-in viewer's standing is eligible with rank 17 +- **THEN** the strip shows rank 17 and their raw rating + +#### Scenario: Provisional viewer with a countdown + +- **WHEN** the standing has `eligible: false` and `gamesRemaining: 4` +- **THEN** the strip shows a Provisional badge and "play 4 more to place" phrasing + +#### Scenario: Provisional viewer without a countdown + +- **WHEN** the standing has `eligible: false` and `gamesRemaining: 0` +- **THEN** the strip shows a Provisional badge with a keep-playing message and never shows "play 0 more" + +#### Scenario: Signed-out visitor + +- **WHEN** no user is signed in +- **THEN** the board renders without a standing strip and without calling the authenticated procedure + +### Requirement: Shared site header provides the leaderboard entry point + +The web app SHALL introduce a minimal shared header component — wordmark/home link, a Leaderboard link, and the Clerk user control (sign-in affordance when signed out) — rendered on the landing page and the leaderboard page. The landing page's existing floating user control SHALL be replaced by the header. + +#### Scenario: Navigating to the leaderboard + +- **WHEN** a visitor on the landing page activates the header's Leaderboard link +- **THEN** they land on `/leaderboard` + +#### Scenario: Returning home + +- **WHEN** a visitor on the leaderboard page activates the wordmark/home link +- **THEN** they land on the landing page + +#### Scenario: Header on both pages + +- **WHEN** either the landing page or the leaderboard page renders +- **THEN** the shared header is present exactly once, and the landing page no longer renders the old floating user control diff --git a/packages/rating/src/config/config.test.ts b/packages/rating/src/config/config.test.ts new file mode 100644 index 0000000..6d6da8a --- /dev/null +++ b/packages/rating/src/config/config.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_LEADERBOARD_ELIGIBILITY, DEFAULT_RATING_CONFIG } from './config'; + +/** + * The calibration constants are contracts: their values are ruled decisions + * (rating design D7; leaderboard eligibility ruled 2026-07-03), and freezing + * guarantees no consumer mutates the shared defaults at runtime. + */ +describe('DEFAULT_LEADERBOARD_ELIGIBILITY', () => { + it('carries the ruled v1 gate values', () => { + expect(DEFAULT_LEADERBOARD_ELIGIBILITY).toEqual({ + maxRatingDeviation: 100, + minGamesPlayed: 10, + }); + }); + + it('is frozen', () => { + expect(Object.isFrozen(DEFAULT_LEADERBOARD_ELIGIBILITY)).toBe(true); + expect(() => { + (DEFAULT_LEADERBOARD_ELIGIBILITY as { maxRatingDeviation: number }).maxRatingDeviation = 999; + }).toThrow(TypeError); + expect(DEFAULT_LEADERBOARD_ELIGIBILITY.maxRatingDeviation).toBe(100); + }); +}); + +describe('DEFAULT_RATING_CONFIG', () => { + it('is frozen', () => { + expect(Object.isFrozen(DEFAULT_RATING_CONFIG)).toBe(true); + }); +}); diff --git a/packages/rating/src/config/config.ts b/packages/rating/src/config/config.ts index 8017638..718f85e 100644 --- a/packages/rating/src/config/config.ts +++ b/packages/rating/src/config/config.ts @@ -45,3 +45,22 @@ export const DEFAULT_RATING_CONFIG: RatingConfig = Object.freeze({ strandedLossFactor: 0.5, convergenceTolerance: 1e-6, }); + +/** + * The public-board eligibility gate (Rating & Ranking §7/§10): a player appears + * on the leaderboard only once their rating is trustworthy — RD at or below the + * maximum AND games played at or above the minimum. Sub-threshold players are + * Provisional. Like {@link RatingConfig}, retuning is a caller-side override. + */ +export interface LeaderboardEligibility { + /** Maximum rating deviation for public-board placement. Default 100. */ + readonly maxRatingDeviation: number; + /** Minimum rated games for public-board placement. Default 10. */ + readonly minGamesPlayed: number; +} + +/** The v1 eligibility gate ruled 2026-07-03 (leaderboard design D4). */ +export const DEFAULT_LEADERBOARD_ELIGIBILITY: LeaderboardEligibility = Object.freeze({ + maxRatingDeviation: 100, + minGamesPlayed: 10, +}); diff --git a/packages/rating/src/index.ts b/packages/rating/src/index.ts index 55a835d..8c95a25 100644 --- a/packages/rating/src/index.ts +++ b/packages/rating/src/index.ts @@ -15,7 +15,12 @@ * exported (design D2) — callers deal only in the familiar r / RD / σ. */ -export { DEFAULT_RATING_CONFIG, type RatingConfig } from './config/config'; +export { + DEFAULT_LEADERBOARD_ELIGIBILITY, + DEFAULT_RATING_CONFIG, + type LeaderboardEligibility, + type RatingConfig, +} from './config/config'; export { createInitialRating, type GameScore, diff --git a/packages/shared/src/api/index.ts b/packages/shared/src/api/index.ts index 4e1203b..456c453 100644 --- a/packages/shared/src/api/index.ts +++ b/packages/shared/src/api/index.ts @@ -57,6 +57,16 @@ export { type ReleaseCommand, type RecruitmentAck, } from './recruit'; +export { + LeaderboardRowSchema, + LeaderboardListInputSchema, + LeaderboardListOutputSchema, + LeaderboardStandingSchema, + LeaderboardMyStandingInputSchema, + LeaderboardMyStandingOutputSchema, + type LeaderboardRow, + type LeaderboardStanding, +} from './leaderboard'; export { TRACE_ID_HEADER } from './trace'; export { SpawnSeatSchema, diff --git a/packages/shared/src/api/leaderboard.ts b/packages/shared/src/api/leaderboard.ts new file mode 100644 index 0000000..7a2b9fe --- /dev/null +++ b/packages/shared/src/api/leaderboard.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; +import { CursorPaginationInputSchema, paginated } from './pagination'; + +/** + * Wire contracts for the public leaderboard surface (capability + * `leaderboard-api`). The board is per-variant, keyset-paginated via the shared + * cursor envelope, and ordered by the conservative estimate `rating − 2·RD` — + * but each row displays the **raw** rating (Rating & Ranking §7). The + * own-standing shape carries the derived eligibility fields so the web client + * never needs the gate constants themselves. + */ + +/** One placed row of the public board, in conservative-sort order. */ +export const LeaderboardRowSchema = z.object({ + /** 1-based dense position in the eligible set (continues across pages). */ + rank: z.number().int().positive(), + playerId: z.string().min(1), + displayName: z.string(), + avatar: z.string().nullable(), + /** The raw Glicko-2 rating — the displayed number, not the sort key. */ + rating: z.number(), + gamesPlayed: z.number().int().nonnegative(), +}); + +export type LeaderboardRow = z.infer; + +export const LeaderboardListInputSchema = CursorPaginationInputSchema.extend({ + variantId: z.string().min(1), +}); + +export const LeaderboardListOutputSchema = paginated(LeaderboardRowSchema); + +/** + * The signed-in viewer's per-variant standing. `rank` is present only when + * `eligible`; `gamesRemaining` is `max(0, minGamesPlayed − gamesPlayed)` — it + * can be 0 while still ineligible (games met, RD above the gate), in which + * case the UI shows a generic keep-playing message, never "play 0 more". + */ +export const LeaderboardStandingSchema = z.object({ + rating: z.number(), + ratingDeviation: z.number(), + gamesPlayed: z.number().int().nonnegative(), + eligible: z.boolean(), + rank: z.number().int().positive().nullable(), + gamesRemaining: z.number().int().nonnegative(), +}); + +export type LeaderboardStanding = z.infer; + +export const LeaderboardMyStandingInputSchema = z.object({ variantId: z.string().min(1) }); +/** `null` when the viewer has never played ranked on the variant. */ +export const LeaderboardMyStandingOutputSchema = LeaderboardStandingSchema.nullable(); diff --git a/packages/shared/src/server/db/leaderboard-store.db.test.ts b/packages/shared/src/server/db/leaderboard-store.db.test.ts new file mode 100644 index 0000000..56fa50e --- /dev/null +++ b/packages/shared/src/server/db/leaderboard-store.db.test.ts @@ -0,0 +1,199 @@ +import { randomUUID } from 'node:crypto'; +import { inArray } from 'drizzle-orm'; +import { afterAll, describe, expect, it } from 'vitest'; +import { createDb, type DatabaseClient } from './client'; +import { createLeaderboardStore, LeaderboardCursorError } from './leaderboard-store'; +import { playerRatings, players } from './schema'; + +/** + * Live-DB behavior tests for the leaderboard store (capability + * `leaderboard-api`) following the rating-store pattern: real Postgres, + * `skipIf` without `DATABASE_URL`, explicit cleanup. Every query the store + * issues is scoped by `variant_id`, so a per-run unique variant id fully + * isolates the suite from concurrent runs and any pre-existing rating rows. + * + * Covered: conservative ordering vs raw rating, the deterministic player-id + * tiebreak, both gate thresholds, banned/anonymized exclusion, a multi-page + * keyset walk (no duplicates/skips, rank continuity, null final cursor), the + * typed cursor failure, and the standing matrix (placed / provisional-games / + * provisional-RD / never-played). + */ +const databaseUrl = process.env.DATABASE_URL; + +const GATE = { maxRatingDeviation: 100, minGamesPlayed: 10 } as const; + +describe.skipIf(!databaseUrl)('leaderboard store (live DB)', () => { + // Construct the client only when a DB is configured — the describe body still + // evaluates during collection even when the suite is skipped. + const db = (databaseUrl ? createDb({ DATABASE_URL: databaseUrl }) : null) as DatabaseClient; + const store = databaseUrl ? createLeaderboardStore(db, GATE) : (null as never); + + const createdPlayerIds: string[] = []; + + /** Insert a human player (+ rating row) on a given variant, returning the player id. */ + async function makeRatedHuman( + variantId: string, + rating: number, + ratingDeviation: number, + gamesPlayed: number, + status: 'active' | 'banned' | 'anonymized' = 'active', + ): Promise { + const [player] = await db + .insert(players) + .values({ + type: 'human', + clerkUserId: `test_clerk_${randomUUID()}`, + displayName: `LB ${rating}/${ratingDeviation}/${gamesPlayed}`, + status, + }) + .returning({ id: players.id }); + if (player === undefined) throw new Error('player insert returned no row'); + createdPlayerIds.push(player.id); + await db.insert(playerRatings).values({ + playerId: player.id, + variantId, + rating, + ratingDeviation, + volatility: 0.06, + gamesPlayed, + lastRatedAt: new Date(), + }); + return player.id; + } + + afterAll(async () => { + if (createdPlayerIds.length > 0) { + await db.delete(playerRatings).where(inArray(playerRatings.playerId, createdPlayerIds)); + await db.delete(players).where(inArray(players.id, createdPlayerIds)); + } + }); + + it('orders by conservative estimate while displaying the raw rating', async () => { + const variantId = `lb-test-${randomUUID()}`; + // A: conservative 1700 − 180 = 1520; B: 1650 − 120 = 1530 → B ranks ahead. + const a = await makeRatedHuman(variantId, 1700, 90, 20); + const b = await makeRatedHuman(variantId, 1650, 60, 20); + + const page = await store.listPage({ variantId, limit: 10 }); + expect(page.items.map((row) => row.playerId)).toEqual([b, a]); + expect(page.items.map((row) => row.rating)).toEqual([1650, 1700]); + expect(page.items.map((row) => row.rank)).toEqual([1, 2]); + expect(page.nextCursor).toBeNull(); + }); + + it('tiebreaks identical conservative estimates by player_id ascending, deterministically', async () => { + const variantId = `lb-test-${randomUUID()}`; + const x = await makeRatedHuman(variantId, 1600, 80, 15); + const y = await makeRatedHuman(variantId, 1600, 80, 15); + const expected = [x, y].sort(); + + const firstRead = await store.listPage({ variantId, limit: 10 }); + const secondRead = await store.listPage({ variantId, limit: 10 }); + expect(firstRead.items.map((row) => row.playerId)).toEqual(expected); + expect(secondRead.items.map((row) => row.playerId)).toEqual(expected); + }); + + it('excludes players failing either gate threshold', async () => { + const variantId = `lb-test-${randomUUID()}`; + const placed = await makeRatedHuman(variantId, 1500, 100, 10); // exactly at both thresholds + await makeRatedHuman(variantId, 1500, 140, 12); // games met, RD above the max + await makeRatedHuman(variantId, 1500, 95, 8); // RD met, below the games floor + + const page = await store.listPage({ variantId, limit: 10 }); + expect(page.items.map((row) => row.playerId)).toEqual([placed]); + }); + + it('excludes banned and anonymized players whose ratings satisfy the gate', async () => { + const variantId = `lb-test-${randomUUID()}`; + const active = await makeRatedHuman(variantId, 1500, 90, 12); + await makeRatedHuman(variantId, 1800, 90, 12, 'banned'); + await makeRatedHuman(variantId, 1800, 90, 12, 'anonymized'); + + const page = await store.listPage({ variantId, limit: 10 }); + expect(page.items.map((row) => row.playerId)).toEqual([active]); + }); + + it('walks pages by keyset without duplicates or skips, ranks continuous, final cursor null', async () => { + const variantId = `lb-test-${randomUUID()}`; + for (const rating of [1700, 1650, 1600, 1550, 1500]) { + await makeRatedHuman(variantId, rating, 60, 20); + } + + const pageOne = await store.listPage({ variantId, limit: 2 }); + expect(pageOne.items.map((row) => row.rank)).toEqual([1, 2]); + expect(pageOne.nextCursor).not.toBeNull(); + + const pageTwo = await store.listPage({ variantId, cursor: pageOne.nextCursor!, limit: 2 }); + expect(pageTwo.items.map((row) => row.rank)).toEqual([3, 4]); + expect(pageTwo.nextCursor).not.toBeNull(); + + const pageThree = await store.listPage({ variantId, cursor: pageTwo.nextCursor!, limit: 2 }); + expect(pageThree.items.map((row) => row.rank)).toEqual([5]); + expect(pageThree.nextCursor).toBeNull(); + + const walked = [...pageOne.items, ...pageTwo.items, ...pageThree.items].map((row) => row.playerId); + expect(new Set(walked).size).toBe(5); + + // The walk equals the single-page read row for row — no duplicates, no skips. + const whole = await store.listPage({ variantId, limit: 10 }); + expect(walked).toEqual(whole.items.map((row) => row.playerId)); + }); + + it('rejects a malformed cursor with the typed error', async () => { + const variantId = `lb-test-${randomUUID()}`; + await expect(store.listPage({ variantId, cursor: 'not-a-cursor', limit: 2 })).rejects.toBeInstanceOf( + LeaderboardCursorError, + ); + // Structurally invalid payloads are rejected too, not just undecodable ones. + const forged = Buffer.from(JSON.stringify({ nope: true }), 'utf8').toString('base64url'); + await expect(store.listPage({ variantId, cursor: forged, limit: 2 })).rejects.toBeInstanceOf(LeaderboardCursorError); + }); + + it('returns an empty board with a null cursor for a variant with no rating rows', async () => { + const page = await store.listPage({ variantId: `lb-test-${randomUUID()}`, limit: 10 }); + expect(page.items).toEqual([]); + expect(page.nextCursor).toBeNull(); + }); + + it('reports a placed player with rank = 1 + eligible players strictly ahead', async () => { + const variantId = `lb-test-${randomUUID()}`; + await makeRatedHuman(variantId, 1700, 60, 20); // conservative 1580 — ahead + const viewer = await makeRatedHuman(variantId, 1600, 60, 15); // conservative 1480 + await makeRatedHuman(variantId, 1500, 60, 20); // conservative 1380 — behind + await makeRatedHuman(variantId, 1900, 200, 20); // ineligible — must not count + + const standing = await store.getStanding({ variantId, playerId: viewer }); + expect(standing).toEqual({ + rating: 1600, + ratingDeviation: 60, + gamesPlayed: 15, + eligible: true, + rank: 2, + gamesRemaining: 0, + }); + }); + + it('reports a provisional player below the games floor with the countdown', async () => { + const variantId = `lb-test-${randomUUID()}`; + const viewer = await makeRatedHuman(variantId, 1550, 90, 6); + + const standing = await store.getStanding({ variantId, playerId: viewer }); + expect(standing).toMatchObject({ eligible: false, rank: null, gamesRemaining: 4 }); + }); + + it('reports games-met-but-RD-high as provisional with zero games remaining', async () => { + const variantId = `lb-test-${randomUUID()}`; + const viewer = await makeRatedHuman(variantId, 1550, 130, 14); + + const standing = await store.getStanding({ variantId, playerId: viewer }); + expect(standing).toMatchObject({ eligible: false, rank: null, gamesRemaining: 0 }); + }); + + it('returns null for a player with no rating row on the variant', async () => { + const variantId = `lb-test-${randomUUID()}`; + const elsewhere = await makeRatedHuman(`lb-test-${randomUUID()}`, 1500, 60, 20); + + const standing = await store.getStanding({ variantId, playerId: elsewhere }); + expect(standing).toBeNull(); + }); +}); diff --git a/packages/shared/src/server/db/leaderboard-store.ts b/packages/shared/src/server/db/leaderboard-store.ts new file mode 100644 index 0000000..d07b56a --- /dev/null +++ b/packages/shared/src/server/db/leaderboard-store.ts @@ -0,0 +1,204 @@ +import { and, asc, desc, eq, gt, gte, lt, lte, or, sql, type SQL } from 'drizzle-orm'; +import { z } from 'zod'; +import type { LeaderboardRow, LeaderboardStanding } from '../../api'; +import type { DatabaseClient } from './client'; +import { playerRatings, players } from './schema'; + +/** + * The leaderboard read path (capability `leaderboard-api`, design D1–D3): + * per-variant conservative-order board pages and the viewer's own standing, + * implemented over `player_ratings` ⋈ `players` and injected into the API as + * an interface so router tests fake it with an object literal. + * + * Ranking semantics (design D2): sort key `rating − 2·rating_deviation` DESC, + * tiebreak `player_id` ASC; eligible = RD ≤ max AND games ≥ min AND the player + * is a live human (`type='human'`, `status='active'`). Pagination is keyset — + * the opaque cursor carries the last row's sort key and its rank so the next + * page resumes strictly after it with continuous rank numbers, stable under + * concurrent rating movement. + */ + +/** The eligibility gate the store filters on (values ruled in `@meldrank/rating`). */ +export interface LeaderboardGate { + readonly maxRatingDeviation: number; + readonly minGamesPlayed: number; +} + +export interface LeaderboardListPageInput { + readonly variantId: string; + /** Opaque continuation token from a prior page's `nextCursor`. */ + readonly cursor?: string; + readonly limit: number; +} + +export interface LeaderboardStandingInput { + readonly variantId: string; + readonly playerId: string; +} + +/** One page of the board, in the shared `{ items, nextCursor }` envelope. */ +export interface LeaderboardPage { + items: LeaderboardRow[]; + nextCursor: string | null; +} + +/** The injectable leaderboard read interface (`ApiDeps.leaderboard`). */ +export interface LeaderboardStore { + /** + * One conservative-ordered page of the eligible board. + * @throws {LeaderboardCursorError} when `cursor` fails to decode/validate. + */ + listPage(input: LeaderboardListPageInput): Promise; + /** The viewer's standing for a variant, or `null` when they never played ranked on it. */ + getStanding(input: LeaderboardStandingInput): Promise; +} + +/** A malformed/forged continuation token — the router maps this to `validation`. */ +export class LeaderboardCursorError extends Error { + constructor(message = 'invalid leaderboard cursor') { + super(message); + this.name = 'LeaderboardCursorError'; + } +} + +/** The decoded continuation key: the last-returned row's sort key and rank. */ +const CursorPayloadSchema = z.object({ + conservative: z.number(), + playerId: z.string().min(1), + rank: z.number().int().positive(), +}); + +type CursorPayload = z.infer; + +function encodeCursor(payload: CursorPayload): string { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); +} + +function decodeCursor(cursor: string): CursorPayload { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + } catch { + throw new LeaderboardCursorError(); + } + const result = CursorPayloadSchema.safeParse(parsed); + if (!result.success) throw new LeaderboardCursorError(); + return result.data; +} + +/** Build the Drizzle-backed leaderboard store over the shared database client. */ +export function createLeaderboardStore(db: DatabaseClient, gate: LeaderboardGate): LeaderboardStore { + // The board's sort key (Rating §7). Computed in SQL so the expression index + // `player_ratings_variant_conservative_idx` matches both the scan and the count. + const conservative = sql`(${playerRatings.rating} - 2 * ${playerRatings.ratingDeviation})`; + + /** The full eligibility gate for a variant: thresholds + live-human join filter. */ + function eligibleWhere(variantId: string): SQL { + return and( + eq(playerRatings.variantId, variantId), + lte(playerRatings.ratingDeviation, gate.maxRatingDeviation), + gte(playerRatings.gamesPlayed, gate.minGamesPlayed), + eq(players.type, 'human'), + eq(players.status, 'active'), + ) as SQL; + } + + return { + async listPage({ variantId, cursor, limit }) { + const after = cursor === undefined ? null : decodeCursor(cursor); + + // Keyset continuation: strictly after (conservative DESC, player_id ASC). + const afterKey = + after === null + ? undefined + : or( + lt(conservative, after.conservative), + and(eq(conservative, after.conservative), gt(playerRatings.playerId, after.playerId)), + ); + + // Fetch one extra row to learn whether a next page exists. + const rows = await db + .select({ + playerId: playerRatings.playerId, + displayName: players.displayName, + avatar: players.avatar, + rating: playerRatings.rating, + gamesPlayed: playerRatings.gamesPlayed, + conservative, + }) + .from(playerRatings) + .innerJoin(players, eq(players.id, playerRatings.playerId)) + .where(and(eligibleWhere(variantId), afterKey)) + .orderBy(desc(conservative), asc(playerRatings.playerId)) + .limit(limit + 1); + + const page = rows.slice(0, limit); + const startRank = after === null ? 1 : after.rank + 1; + const items: LeaderboardRow[] = page.map((row, index) => ({ + rank: startRank + index, + playerId: row.playerId, + displayName: row.displayName, + avatar: row.avatar, + rating: row.rating, + gamesPlayed: row.gamesPlayed, + })); + + const last = page.at(-1); + const nextCursor = + rows.length > limit && last !== undefined + ? encodeCursor({ + conservative: last.conservative, + playerId: last.playerId, + rank: startRank + page.length - 1, + }) + : null; + + return { items, nextCursor }; + }, + + async getStanding({ variantId, playerId }) { + const rows = await db + .select({ + rating: playerRatings.rating, + ratingDeviation: playerRatings.ratingDeviation, + gamesPlayed: playerRatings.gamesPlayed, + }) + .from(playerRatings) + .where(and(eq(playerRatings.playerId, playerId), eq(playerRatings.variantId, variantId))) + .limit(1); + + const row = rows[0]; + if (row === undefined) return null; + + const eligible = row.ratingDeviation <= gate.maxRatingDeviation && row.gamesPlayed >= gate.minGamesPlayed; + const gamesRemaining = Math.max(0, gate.minGamesPlayed - row.gamesPlayed); + + let rank: number | null = null; + if (eligible) { + // 1 + the count of eligible rows sorting strictly ahead of the viewer + // under (conservative DESC, player_id ASC). + const mine = row.rating - 2 * row.ratingDeviation; + const ahead = await db + .select({ count: sql`count(*)::int` }) + .from(playerRatings) + .innerJoin(players, eq(players.id, playerRatings.playerId)) + .where( + and( + eligibleWhere(variantId), + or(gt(conservative, mine), and(eq(conservative, mine), lt(playerRatings.playerId, playerId))), + ), + ); + rank = (ahead[0]?.count ?? 0) + 1; + } + + return { + rating: row.rating, + ratingDeviation: row.ratingDeviation, + gamesPlayed: row.gamesPlayed, + eligible, + rank, + gamesRemaining, + }; + }, + }; +} diff --git a/packages/shared/src/server/db/schema/ratings.ts b/packages/shared/src/server/db/schema/ratings.ts index fd23289..4e6e422 100644 --- a/packages/shared/src/server/db/schema/ratings.ts +++ b/packages/shared/src/server/db/schema/ratings.ts @@ -1,3 +1,4 @@ +import { sql } from 'drizzle-orm'; import { doublePrecision, index, integer, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core'; import { ratingResult } from './enums'; import { matches } from './matches'; @@ -32,7 +33,17 @@ export const playerRatings = pgTable( lastRatedAt: timestamp('last_rated_at', { withTimezone: true }).notNull(), seasonId: uuid('season_id'), }, - (table) => [primaryKey({ columns: [table.playerId, table.variantId] })], + (table) => [ + primaryKey({ columns: [table.playerId, table.variantId] }), + // Backs the leaderboard's per-variant conservative-order scan and the + // own-rank count (Rating & Ranking §7): sort key `rating − 2·RD` DESC with + // the deterministic `player_id` tiebreak, prefixed by `variant_id`. + index('player_ratings_variant_conservative_idx').on( + table.variantId, + sql`(${table.rating} - 2 * ${table.ratingDeviation}) DESC`, + table.playerId, + ), + ], ); /** diff --git a/packages/shared/src/server/index.ts b/packages/shared/src/server/index.ts index 7bc5610..c395342 100644 --- a/packages/shared/src/server/index.ts +++ b/packages/shared/src/server/index.ts @@ -14,6 +14,7 @@ export { allEnvKeys } from './env/keys'; export * from './db/client'; export * as dbSchema from './db/schema'; export * from './db/projector'; +export * from './db/leaderboard-store'; export * from './redis/client'; /** Server-only seat-ticket sign/verify helper (the payload schema is isomorphic, on the root). */ export { signSeatTicket, verifySeatTicket } from './api/ticket';