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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/game/src/routes/leaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import type { FastifyInstance } from "fastify";
import { createHmac } from "node:crypto";

import { loadFixtures2026 } from "@tournamental/bracket-engine";

import {
globalKey,
syndicateKey,
Expand All @@ -28,6 +30,50 @@ import type { LeaderboardRow } from "../types.js";
const TOP_N = 100;
const PUBLIC_CACHE_HEADER = "public, max-age=30, stale-while-revalidate=60";

/**
* Per-tournament catalogue of fixture kickoff epoch-ms, sorted ascending.
* Used to compute `matches_available_to_user` = count of fixtures whose
* kickoff has happened AND landed strictly after the user's
* `registered_at` timestamp. Cached for the process lifetime; FIFA
* 2026 fixtures don't change.
*/
const kickoffCatalogueCache = new Map<string, readonly number[]>();

function kickoffCatalogue(tournamentId: string): readonly number[] {
const cached = kickoffCatalogueCache.get(tournamentId);
if (cached) return cached;
const kickoffs: number[] = [];
if (tournamentId === "fifa-wc-2026") {
const tournament = loadFixtures2026();
for (const f of tournament.group_fixtures) {
const ms = Date.parse(f.kickoff_utc);
if (!Number.isNaN(ms)) kickoffs.push(ms);
}
for (const k of tournament.knockouts) {
if (!k.kickoff_utc) continue;
const ms = Date.parse(k.kickoff_utc);
if (!Number.isNaN(ms)) kickoffs.push(ms);
}
kickoffs.sort((a, b) => a - b);
}
kickoffCatalogueCache.set(tournamentId, kickoffs);
return kickoffs;
}

/** Count of fixtures whose kickoff is <= `now` and > `registeredAt`. */
function matchesAvailableTo(
tournamentId: string,
registeredAt: number,
now: number,
): number {
const kickoffs = kickoffCatalogue(tournamentId);
let count = 0;
for (const k of kickoffs) {
if (k <= now && k > registeredAt) count += 1;
}
return count;
}

/**
* SEC-BRK-06: opaque per-user identifier emitted in place of the raw
* `user_id`. HMAC keyed by `LEADERBOARD_HANDLE_SECRET` (or the admin
Expand Down Expand Up @@ -76,12 +122,18 @@ export async function registerLeaderboardRoutes(
return { tournament_id: tournamentId, rows: cached };
}
const rows = deps.store.topN(tournamentId, TOP_N);
const now = Date.now();
const out: LeaderboardRow[] = rows.map((r, i) => ({
rank: i + 1,
user_handle: hashUserHandle(r.user_id),
share_guid: r.share_guid,
score_total: r.score_total,
bracket_id: r.id,
matches_available_to_user: matchesAvailableTo(
tournamentId,
r.joined_at,
now,
),
}));
deps.cache.set(key, out);
reply.header("Cache-Control", PUBLIC_CACHE_HEADER);
Expand Down Expand Up @@ -116,12 +168,18 @@ export async function registerLeaderboardRoutes(
};
}
const rows = deps.store.topNForSyndicate(tournamentId, syndicateId, TOP_N);
const now = Date.now();
const out: LeaderboardRow[] = rows.map((r, i) => ({
rank: i + 1,
user_handle: hashUserHandle(r.user_id),
share_guid: r.share_guid,
score_total: r.score_total,
bracket_id: r.id,
matches_available_to_user: matchesAvailableTo(
tournamentId,
r.joined_at,
now,
),
}));
deps.cache.set(key, out);
reply.header("Cache-Control", PUBLIC_CACHE_HEADER);
Expand Down
65 changes: 58 additions & 7 deletions apps/game/src/store/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ export interface BracketRow {
share_guid: string | null;
}

/** Leaderboard-only view of a bracket row. Adds the user's "registered
* at" timestamp so the route can compute `matches_available_to_user`
* (count of fixtures kicked off after the user could start picking).
*
* - For syndicate scope: `joined_at` = `syndicate_members.joined_at`
* for the (user, syndicate) pair.
* - For global scope: `joined_at` falls back to the bracket's
* `locked_at` — when the user first committed predictions to the
* tournament. Imperfect (re-saves push it forward) but works for
* v0.1; a follow-up will introduce a `brackets.created_at`
* immutable column. Tim 2026-06-07. */
export interface LeaderboardBracketRow extends BracketRow {
joined_at: number;
}

export interface MatchResultRow {
match_id: string;
tournament_id: string;
Expand Down Expand Up @@ -96,6 +111,7 @@ export class GameStore {
private leaderboardStmt!: Statement;
private leaderboardSyndicateStmt!: Statement;
private upsertSyndicateMemberStmt!: Statement;
private upsertSyndicateOwnerMembershipStmt!: Statement;
private upsertTournamentStmt!: Statement;
private setTournamentSettledStmt!: Statement;
private getTournamentStmt!: Statement;
Expand Down Expand Up @@ -215,16 +231,40 @@ export class GameStore {
// and is already exposed by `/v1/bracket/by-guid`; it's the
// navigable identity for a row that no longer carries `user_id`.
this.leaderboardStmt = this.db.prepare(
`SELECT id, user_id, score_total, share_guid
// Tim 2026-06-07: surface `locked_at` so the route can use it as
// the fallback per-user "registered at" timestamp for global
// scope (see LeaderboardBracketRow.joined_at).
`SELECT id, user_id, score_total, share_guid, locked_at, locked_at AS joined_at
FROM brackets
WHERE tournament_id = ?
ORDER BY score_total DESC, locked_at ASC, user_id ASC
LIMIT ?`,
);
this.leaderboardSyndicateStmt = this.db.prepare(
`SELECT b.id, b.user_id, b.score_total, b.share_guid
// Tim 2026-06-07: also return `sm.joined_at` so the route can
// compute `matches_available_to_user` (count of fixtures kicked
// off after this user joined the pool).
//
// Two membership tables exist in this DB. `syndicate_members`
// (this service's own schema, see 0001_init.sql) is empty because
// the join flow lives in apps/web and writes to
// `syndicate_owners_membership` (see apps/web/lib/syndicate/
// persistence.ts). The web table is the authoritative one for
// who-joined-which-pool, so the leaderboard JOINs against it.
// The legacy `syndicate_members` table stays for backward-compat
// until a follow-up consolidates.
// Status filtering deliberately omitted: the `status` column on
// syndicate_owners_membership is added at runtime by the web
// persistence layer's ALTER TABLE (apps/web/lib/syndicate/
// persistence.ts) and doesn't exist in the game service's
// migration 0003. Referencing it would PREPARE-error in tests.
// Pending / denied members showing up on the leaderboard is a
// known small edge — a follow-up will land a 0011 migration
// adding the column to the game schema so this query can gate
// on `(sm.status IS NULL OR sm.status = 'active')`.
`SELECT b.id, b.user_id, b.score_total, b.share_guid, b.locked_at, sm.joined_at AS joined_at
FROM brackets b
INNER JOIN syndicate_members sm
INNER JOIN syndicate_owners_membership sm
ON sm.user_id = b.user_id
WHERE b.tournament_id = ? AND sm.syndicate_id = ?
ORDER BY b.score_total DESC, b.locked_at ASC, b.user_id ASC
Expand All @@ -235,6 +275,16 @@ export class GameStore {
VALUES (?, ?, ?)
ON CONFLICT(user_id, syndicate_id) DO NOTHING`,
);
// Mirror the join into the table the leaderboard query actually
// reads. The web layer also writes here on its own /join flow;
// we dual-write from the game route so the legacy
// `addSyndicateMember` path (still used by the bot + tests) shows
// up on the leaderboard. Tim 2026-06-07.
this.upsertSyndicateOwnerMembershipStmt = this.db.prepare(
`INSERT INTO syndicate_owners_membership (syndicate_id, user_id, role, joined_at)
VALUES (?, ?, 'member', ?)
ON CONFLICT(syndicate_id, user_id) DO NOTHING`,
);
this.upsertTournamentStmt = this.db.prepare(
`INSERT INTO tournaments (id, name, settled_at, created_at)
VALUES (@id, @name, @settled_at, @created_at)
Expand Down Expand Up @@ -460,26 +510,27 @@ export class GameStore {

// ---------- leaderboards ----------

topN(tournamentId: string, n: number): BracketRow[] {
return this.leaderboardStmt.all(tournamentId, n) as BracketRow[];
topN(tournamentId: string, n: number): LeaderboardBracketRow[] {
return this.leaderboardStmt.all(tournamentId, n) as LeaderboardBracketRow[];
}

topNForSyndicate(
tournamentId: string,
syndicateId: string,
n: number,
): BracketRow[] {
): LeaderboardBracketRow[] {
return this.leaderboardSyndicateStmt.all(
tournamentId,
syndicateId,
n,
) as BracketRow[];
) as LeaderboardBracketRow[];
}

// ---------- syndicate ----------

addSyndicateMember(userId: string, syndicateId: string, now = Date.now()): void {
this.ensureUser(userId, now);
this.upsertSyndicateOwnerMembershipStmt.run(syndicateId, userId, now);
this.upsertSyndicateMemberStmt.run(userId, syndicateId, now);
}

Expand Down
15 changes: 15 additions & 0 deletions apps/game/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,19 @@ export interface LeaderboardRow {
readonly share_guid: string | null;
readonly score_total: number;
readonly bracket_id: string;
/**
* Count of tournament fixtures whose kickoff_utc has elapsed AND
* landed strictly after this user's "registered at" timestamp.
* Denominator for the "X / Y" display: how many matches has this
* particular user actually been around to score on?
*
* - For syndicate scope: registered_at = syndicate_members.joined_at.
* - For global scope: registered_at = brackets.locked_at (the
* first time the user committed predictions to the tournament).
*
* 0 before any fixture has kicked off (the entire tournament is
* pre-kickoff today). Renders as `0 / 0` until the first match
* starts, then ticks up. Tim 2026-06-07.
*/
readonly matches_available_to_user: number;
}
156 changes: 156 additions & 0 deletions apps/web/__tests__/MatchVenueFooter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* MatchVenueFooter unit tests.
*
* Coverage:
* - Renders date + time + gold info icon.
* - Uses venue timezone on first render (pre-hydration), then swaps
* to the user's timezone after `useEffect` resolves.
* - Renders an accessible name on the <a> wrapper.
* - Click with an overlay router fires `overlay.open("match", ...)`
* and calls `preventDefault`. Click without an overlay falls
* through to a real navigation (we just assert it didn't crash
* and the default wasn't prevented).
* - With no `hostCity`, falls back to UTC and still renders.
*/

// @vitest-environment jsdom

import React from "react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";

import { MatchVenueFooter } from "../components/bracket/MatchVenueFooter";
import { OverlayProvider, useOverlay } from "../components/overlay/OverlayProvider";
import { hostCityById } from "../lib/host-cities";

const MEXICO_CITY = hostCityById("mexico_city")!;
const KICKOFF = "2026-06-11T19:00:00Z";

describe("MatchVenueFooter", () => {
beforeEach(() => {
// Pin the user's IANA timezone to Pacific/Auckland so the
// post-hydration swap is deterministic across machines.
vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({
locale: "en-NZ",
timeZone: "Pacific/Auckland",
calendar: "gregory",
numberingSystem: "latn",
} as unknown as Intl.ResolvedDateTimeFormatOptions);
});

it("renders a tap-target <a> with an accessible name", async () => {
await act(async () => {
render(
<MatchVenueFooter
matchId="1"
homeName="Mexico"
awayName="South Africa"
kickoffIso={KICKOFF}
hostCity={MEXICO_CITY}
/>,
);
});
const link = screen.getByRole("link");
expect(link.getAttribute("aria-label")).toMatch(/Mexico vs South Africa/);
expect(link.getAttribute("aria-label")).toMatch(/kicks off/);
});

it("includes the gold info icon as an aria-hidden SVG", async () => {
await act(async () => {
render(
<MatchVenueFooter
matchId="1"
homeName="Mexico"
awayName="South Africa"
kickoffIso={KICKOFF}
hostCity={MEXICO_CITY}
/>,
);
});
const link = screen.getByRole("link");
const svg = link.querySelector("svg");
expect(svg).not.toBeNull();
expect(svg!.getAttribute("aria-hidden")).toBe("true");
});

it("renders the kickoff date in the resolved (user) timezone after hydration", async () => {
await act(async () => {
render(
<MatchVenueFooter
matchId="1"
homeName="Mexico"
awayName="South Africa"
kickoffIso={KICKOFF}
hostCity={MEXICO_CITY}
/>,
);
});
// 2026-06-11T19:00 UTC = 2026-06-12 07:00 Pacific/Auckland.
// Assert the rendered date corresponds to NZ-side of the
// dateline, not the venue's Mexico-side.
const text = screen.getByRole("link").textContent ?? "";
expect(text).toMatch(/Fri/); // 12 Jun 2026 is a Friday in Auckland
});

it("falls back to UTC when hostCity is absent", async () => {
await act(async () => {
render(
<MatchVenueFooter
matchId="1"
homeName="A"
awayName="B"
kickoffIso={KICKOFF}
/>,
);
});
// Without a hostCity, the SSR/initial timezone is UTC; after
// hydration we still swap to the user TZ (mocked Auckland). The
// link should still render without crashing.
expect(screen.getByRole("link")).toBeDefined();
});

it("opens the overlay router on plain click", async () => {
let api: ReturnType<typeof useOverlay> | null = null;
const Capture = (): React.ReactElement => {
api = useOverlay();
return <></>;
};
await act(async () => {
render(
<OverlayProvider>
<Capture />
<MatchVenueFooter
matchId="1"
homeName="Mexico"
awayName="South Africa"
kickoffIso={KICKOFF}
hostCity={MEXICO_CITY}
/>
</OverlayProvider>,
);
});
fireEvent.click(screen.getByRole("link"), { button: 0 });
expect(api!.stack).toHaveLength(1);
expect(api!.stack[0]!.kind).toBe("match");
expect(api!.stack[0]!.params.id).toBe("1");
});

it("falls back to a real link href when no overlay provider is mounted", async () => {
await act(async () => {
render(
<MatchVenueFooter
matchId="42"
homeName="A"
awayName="B"
kickoffIso={KICKOFF}
hostCity={MEXICO_CITY}
/>,
);
});
// No overlay router available, so the component should still
// render an <a href> that a browser would follow on click.
expect(screen.getByRole("link").getAttribute("href")).toBe(
"/match/42/preview",
);
});
});
Loading
Loading