Files
game-scoreboard/src/lib/queries/scoring.ts
T
joshuaandClaude Opus 5.5 17eb76d036 Phase 4: scoreboard page, public API and realtime refresh
- buildScoreboardPayload is the single place deciding what the public may
  see: visible, frozen (snapshot scores, live current game), hidden (no
  scores, alphabetical) and reveal (unrevealed teams carry no score/order).
- GET /api/public/scoreboard/[token] validates the token, then loads the
  event with the service client in one parallel round trip.
- notify() pings sb/admin/game channels over Realtime's REST endpoint with
  no data; useRefreshChannel refetches immediately on a ping, throttles
  bursts, and falls back to a 10s poll, focus and reconnect.
- /s/[token]: FLIP rank animation, count-up totals, reduced motion,
  fullscreen, cursor hide and a connection indicator.
- Admin setup actions now notify, so renames and reorders show up live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:19:03 +02:00

110 lines
3.8 KiB
TypeScript

import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database, Tables } from "@/lib/database.types";
import type { ScoringInput } from "@/lib/scoring";
type Client = SupabaseClient<Database>;
export type EventData = {
event: Tables<"events">;
teams: Tables<"teams">[];
games: Tables<"games">[];
criteria: Tables<"criteria">[];
judges: Tables<"judges">[];
scores: Tables<"scores">[];
adjustments: Tables<"adjustments">[];
};
const PAGE_SIZE = 1000;
// `games!inner()` joins through games purely to filter by event, so child rows load in
// the same round trip as the games themselves instead of waiting for their ids.
const VIA_GAME = "*, games!inner()";
/** Supabase returns at most 1000 rows per request; a busy night has more scores than that. */
async function loadAllScores(supabase: Client, eventId: string): Promise<Tables<"scores">[]> {
const rows: Tables<"scores">[] = [];
for (let from = 0; ; from += PAGE_SIZE) {
const { data, error } = await supabase
.from("scores")
.select(VIA_GAME)
.eq("games.event_id", eventId)
.order("id")
.range(from, from + PAGE_SIZE - 1)
.overrideTypes<Tables<"scores">[], { merge: false }>();
if (error) throw new Error(`Could not load scores: ${error.message}`);
rows.push(...data);
if (data.length < PAGE_SIZE) return rows;
}
}
/**
* Everything the scoring engine needs for one event, in one parallel round trip.
* Shared by the admin pages (RLS client) and the public/judge APIs (service client,
* after the token has been validated).
*/
export async function loadEventData(supabase: Client, event: Tables<"events">): Promise<EventData> {
const [teams, games, adjustments, criteria, judges, scores] = await Promise.all([
supabase.from("teams").select("*").eq("event_id", event.id).order("sort_order").order("created_at"),
supabase.from("games").select("*").eq("event_id", event.id).order("sort_order").order("created_at"),
supabase.from("adjustments").select("*").eq("event_id", event.id).order("created_at"),
supabase
.from("criteria")
.select(VIA_GAME)
.eq("games.event_id", event.id)
.order("sort_order")
.overrideTypes<Tables<"criteria">[], { merge: false }>(),
supabase
.from("judges")
.select(VIA_GAME)
.eq("games.event_id", event.id)
.order("created_at")
.overrideTypes<Tables<"judges">[], { merge: false }>(),
loadAllScores(supabase, event.id),
]);
const error = teams.error ?? games.error ?? adjustments.error ?? criteria.error ?? judges.error;
if (error) throw new Error(`Could not load the event: ${error.message}`);
return {
event,
teams: teams.data ?? [],
games: games.data ?? [],
adjustments: adjustments.data ?? [],
criteria: criteria.data ?? [],
judges: judges.data ?? [],
scores,
};
}
/** Maps DB rows to the scoring engine's plain input. */
export function toScoringInput(data: EventData): ScoringInput {
return {
teams: data.teams.map((team) => ({ id: team.id, sortOrder: team.sort_order })),
games: data.games.map((game) => ({
id: game.id,
scoringMode: game.scoring_mode,
pointsMode: game.points_mode,
multiplier: game.multiplier,
placementPoints: game.placement_points,
status: game.status,
})),
criteria: data.criteria.map((criterion) => ({
id: criterion.id,
gameId: criterion.game_id,
weight: criterion.weight,
})),
judges: data.judges.map((judge) => ({ id: judge.id, gameId: judge.game_id, revoked: judge.revoked })),
scores: data.scores.map((score) => ({
gameId: score.game_id,
teamId: score.team_id,
criterionId: score.criterion_id,
judgeId: score.judge_id,
value: score.value,
})),
adjustments: data.adjustments.map((adjustment) => ({
teamId: adjustment.team_id,
points: adjustment.points,
})),
};
}