- 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>
110 lines
3.8 KiB
TypeScript
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,
|
|
})),
|
|
};
|
|
}
|