Squash merge feature/library-reorganization

This commit is contained in:
Codex
2026-04-22 06:46:23 -05:00
parent 7f4a4beb5a
commit fe2a04343c
72 changed files with 14520 additions and 0 deletions

26
frontend/src/lib/media.ts Normal file
View File

@@ -0,0 +1,26 @@
function formatSecondsValue(milliseconds: number): string {
return (milliseconds / 1000).toFixed(1);
}
export function formatClipRange(startMs: number, endMs: number): string {
return `${formatSecondsValue(startMs)}s to ${formatSecondsValue(endMs)}s`;
}
export function formatPlaybackPosition(milliseconds: number): string {
const roundedSeconds = Math.round(Math.max(0, milliseconds) / 100) / 10;
const wholeSeconds = Math.floor(roundedSeconds);
const tenths = Math.round((roundedSeconds - wholeSeconds) * 10);
const hours = Math.floor(wholeSeconds / 3600);
const minutes = Math.floor((wholeSeconds % 3600) / 60);
const seconds = wholeSeconds % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${tenths}`;
}
if (minutes > 0) {
return `${minutes}:${String(seconds).padStart(2, "0")}.${tenths}`;
}
return `${seconds}.${tenths}s`;
}

View File

@@ -0,0 +1,19 @@
import type { GamePrepResponse } from "../api/types";
const KEY_PREFIX = "walkup-prep:";
export function savePreparedGame(gameId: string, payload: GamePrepResponse): void {
localStorage.setItem(`${KEY_PREFIX}${gameId}`, JSON.stringify(payload));
}
export function loadPreparedGame(gameId: string): GamePrepResponse | null {
const raw = localStorage.getItem(`${KEY_PREFIX}${gameId}`);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as GamePrepResponse;
} catch {
return null;
}
}

View File

@@ -0,0 +1,4 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient();

View File

@@ -0,0 +1,186 @@
import teamsnapScriptUrl from "teamsnap.js/lib/teamsnap.js?url";
import { api } from "../api/client";
import type {
TeamSnapAssignment,
TeamSnapAvailability,
TeamSnapEvent,
TeamSnapEventLineup,
TeamSnapEventLineupEntry,
TeamSnapMember,
TeamSnapTeam,
TeamSnapUser,
} from "../api/types";
type TeamSnapSdk = {
auth?: (token: string) => Promise<void> | void;
enablePersistence?: () => void;
loadCollections?: () => Promise<void>;
loadMe?: () => Promise<TeamSnapUser>;
loadTeams?: (...args: unknown[]) => Promise<TeamSnapTeam[]>;
loadMembers?: (params: unknown) => Promise<TeamSnapMember[]>;
loadEvents?: (params: unknown) => Promise<TeamSnapEvent[]>;
loadEventLineups?: (params: unknown) => Promise<TeamSnapEventLineup[]>;
loadAvailabilities?: (params: unknown) => Promise<TeamSnapAvailability[]>;
loadAssignments?: (params: unknown) => Promise<TeamSnapAssignment[]>;
bulkLoad?: (teamId: string | number, typesOrParams?: unknown) => Promise<TeamSnapBulkItem[]>;
createEventLineup?: (data?: Record<string, unknown>) => unknown;
saveEventLineup?: (eventLineup: unknown) => Promise<unknown> | void;
deleteEventLineup?: (eventLineup: unknown) => Promise<unknown> | void;
createEventLineupEntry?: (data?: Record<string, unknown>) => unknown;
saveEventLineupEntry?: (eventLineupEntry: unknown) => Promise<unknown> | void;
deleteEventLineupEntry?: (eventLineupEntry: unknown) => Promise<unknown> | void;
memberName?: (member: TeamSnapMember, reverse?: boolean, forSort?: boolean) => string;
collections?: {
};
};
type TeamSnapBulkItem = {
type?: string;
id?: number | string;
eventId?: number | string;
eventLineupId?: number | string;
memberId?: number | string;
sequence?: number | string | null;
[key: string]: unknown;
};
let sdkPromise: Promise<TeamSnapSdk> | null = null;
declare global {
interface Window {
teamsnap?: TeamSnapSdk;
}
}
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(`script[data-sdk="teamsnap"][src="${src}"]`);
if (existing) {
if (window.teamsnap) {
resolve();
return;
}
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error("Failed to load TeamSnap SDK")), { once: true });
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.dataset.sdk = "teamsnap";
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load TeamSnap SDK"));
document.head.appendChild(script);
});
}
async function getSdk(): Promise<TeamSnapSdk> {
if (!sdkPromise) {
sdkPromise = loadScript(teamsnapScriptUrl).then(() => {
if (!window.teamsnap) {
throw new Error("TeamSnap SDK did not initialize");
}
return window.teamsnap;
});
}
return sdkPromise;
}
async function ensureAuthorized(): Promise<TeamSnapSdk> {
const sdk = await getSdk();
const token = await api.getTeamSnapToken();
(sdk as TeamSnapSdk & { apiUrl?: string; authUrl?: string }).apiUrl = token.api_root;
(sdk as TeamSnapSdk & { apiUrl?: string; authUrl?: string }).authUrl = token.auth_url;
if (sdk.auth) {
await sdk.auth(token.access_token);
}
if (sdk.loadCollections) {
await sdk.loadCollections();
}
if (sdk.enablePersistence) {
sdk.enablePersistence();
}
return sdk;
}
export const teamsnapClient = {
async loadMe(): Promise<TeamSnapUser | null> {
const sdk = await ensureAuthorized();
if (sdk.loadMe) {
return sdk.loadMe();
}
return null;
},
async loadTeams(): Promise<TeamSnapTeam[]> {
const sdk = await ensureAuthorized();
if (sdk.loadTeams) {
const teams = await sdk.loadTeams();
return teams.filter((team) => team.isRetired !== true);
}
return [];
},
async loadMembers(teamId: string): Promise<TeamSnapMember[]> {
const sdk = await ensureAuthorized();
if (sdk.loadMembers) {
return sdk.loadMembers({ teamId });
}
return [];
},
async loadEvents(teamId: string): Promise<TeamSnapEvent[]> {
const sdk = await ensureAuthorized();
if (sdk.loadEvents) {
return sdk.loadEvents({ teamId });
}
return [];
},
async loadAvailabilities(teamId: string, eventId?: string): Promise<TeamSnapAvailability[]> {
const sdk = await ensureAuthorized();
if (sdk.loadAvailabilities) {
return sdk.loadAvailabilities(eventId ? { teamId, eventId } : { teamId });
}
return [];
},
async loadAssignments(teamId: string, eventId?: string): Promise<TeamSnapAssignment[]> {
const sdk = await ensureAuthorized();
if (sdk.loadAssignments) {
return sdk.loadAssignments(eventId ? { teamId, eventId } : { teamId });
}
return [];
},
async loadEventLineupData(teamId: string, eventId: string): Promise<{
eventLineup: TeamSnapEventLineup | null;
entries: TeamSnapEventLineupEntry[];
}> {
const sdk = await ensureAuthorized();
if (!sdk.loadEventLineups) {
return { eventLineup: null, entries: [] };
}
const eventLineups = await sdk.loadEventLineups(eventId);
const eventLineup = eventLineups.length ? eventLineups[eventLineups.length - 1] : null;
const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & {
loadItems?: (linkName: string) => Promise<TeamSnapEventLineupEntry[]>;
} | null;
if (!eventLineupWithLinks?.loadItems) {
return { eventLineup, entries: [] };
}
try {
const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries");
const entries = rawEntries
.filter((item): item is TeamSnapEventLineupEntry => item.type === "eventLineupEntry")
.sort((left, right) => {
const leftSequence = Number(left.sequence ?? Number.MAX_SAFE_INTEGER);
const rightSequence = Number(right.sequence ?? Number.MAX_SAFE_INTEGER);
return leftSequence - rightSequence;
});
return { eventLineup, entries };
} catch {
return { eventLineup, entries: [] };
}
},
};

View File

@@ -0,0 +1,334 @@
import type {
TeamSnapAssignment,
TeamSnapAvailability,
TeamSnapEvent,
TeamSnapEventLineupEntry,
TeamSnapMember,
TeamSnapTeam,
} from "../api/types";
function asDisplayText(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
export function toDate(value: Date | string | undefined | null): Date | null {
if (!value) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
export function formatMemberName(member: TeamSnapMember | null | undefined): string {
if (!member) {
return "Unknown player";
}
const sdkName = typeof window !== "undefined" ? window.teamsnap?.memberName?.(member) : "";
if (typeof sdkName === "string" && sdkName.trim()) {
return sdkName.trim();
}
const name =
asDisplayText(member.name) ||
asDisplayText(member.fullName) ||
asDisplayText(member.displayName) ||
[asDisplayText(member.firstName), asDisplayText(member.lastName)].filter(Boolean).join(" ").trim();
return name || `Player ${member.id}`;
}
export function formatMemberJerseyNumber(member: TeamSnapMember | null | undefined): string {
if (!member) {
return "";
}
const value =
member.number ??
member.jerseyNumber ??
member.jersey_number;
const text = asDisplayText(value);
return text ? `#${text}` : "";
}
export function formatTeamLabel(team: TeamSnapTeam | null | undefined): string {
if (!team) {
return "No team selected";
}
const teamName = asDisplayText(team.name) || `Team ${team.id}`;
const seasonName = asDisplayText(team.seasonName);
return seasonName ? `${teamName} (${seasonName})` : teamName;
}
export function findCurrentPlayer(externalUserId: string | number | null | undefined, members: TeamSnapMember[]): TeamSnapMember | null {
if (externalUserId == null || externalUserId === "") {
return null;
}
const meId = String(externalUserId);
return (
members.find((member) => member.userId != null && String(member.userId) === meId) ??
null
);
}
export function formatGameTitle(game: TeamSnapEvent): string {
const name = asDisplayText(game.name);
if (name) {
return name;
}
const opponentName = asDisplayText(game.opponentName);
if (opponentName) {
return `vs ${opponentName}`;
}
return `Game ${game.id}`;
}
export function formatGameDate(game: TeamSnapEvent): string {
const date = toDate(game.startDate);
if (!date) {
return "Date TBD";
}
return date.toLocaleString([], {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
export function sortGames(events: TeamSnapEvent[]): TeamSnapEvent[] {
return [...events]
.filter((event) => event.isGame)
.sort((left, right) => {
const leftTime = toDate(left.startDate)?.getTime() ?? Number.MAX_SAFE_INTEGER;
const rightTime = toDate(right.startDate)?.getTime() ?? Number.MAX_SAFE_INTEGER;
return leftTime - rightTime;
});
}
export function findNextGame(games: TeamSnapEvent[]): TeamSnapEvent | null {
const now = Date.now();
return games.find((game) => {
const start = toDate(game.startDate);
return start ? start.getTime() >= now : false;
}) ?? games[0] ?? null;
}
function toId(value: number | string | undefined | null): string {
return value == null ? "" : String(value);
}
function toSequence(value: number | string | null | undefined): number {
if (value == null || value === "") {
return Number.MAX_SAFE_INTEGER;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
}
function getMemberLookupName(member: TeamSnapMember): string {
return [asDisplayText(member.firstName), asDisplayText(member.lastName)].filter(Boolean).join(" ").trim();
}
function getLineupEntryMemberName(entry: TeamSnapEventLineupEntry): string {
return asDisplayText(entry.memberName);
}
function getMemberLastName(member: TeamSnapMember): string {
return asDisplayText(member.lastName);
}
function matchesLineupEntry(member: TeamSnapMember, entry: TeamSnapEventLineupEntry): boolean {
const memberId = toId(member.id);
if (memberId && toId(entry.memberId) === memberId) {
return true;
}
const memberName = getMemberLookupName(member);
const entryMemberName = getLineupEntryMemberName(entry);
return memberName !== "" && entryMemberName !== "" && memberName === entryMemberName;
}
export function findLineupEntryForMember(
member: TeamSnapMember,
lineupEntries: TeamSnapEventLineupEntry[],
): TeamSnapEventLineupEntry | null {
const matchingEntries = lineupEntries
.filter((entry) => matchesLineupEntry(member, entry))
.sort((left, right) => {
const leftSequence = toSequence(left.sequence);
const rightSequence = toSequence(right.sequence);
if (leftSequence !== rightSequence) {
return leftSequence - rightSequence;
}
return toId(left.id).localeCompare(toId(right.id));
});
return matchingEntries[0] ?? null;
}
export function orderMembersByAssignments(
members: TeamSnapMember[],
assignments: TeamSnapAssignment[],
): TeamSnapMember[] {
if (!assignments.length) {
return members;
}
const byId = new Map(members.map((member) => [toId(member.id), member] as const));
const ordered: TeamSnapMember[] = [];
const seen = new Set<string>();
for (const assignment of assignments) {
const memberId = toId(assignment.memberId);
if (!memberId || seen.has(memberId)) {
continue;
}
const member = byId.get(memberId);
if (!member) {
continue;
}
ordered.push(member);
seen.add(memberId);
}
for (const member of members) {
const memberId = toId(member.id);
if (seen.has(memberId)) {
continue;
}
ordered.push(member);
}
return ordered;
}
export function isPlayerMember(member: TeamSnapMember): boolean {
return member.isNonPlayer !== true;
}
export function getAvailabilityRank(statusCode: number | null | undefined): number {
if (statusCode === 1) {
return 0;
}
if (statusCode === 2) {
return 1;
}
if (statusCode == null) {
return 2;
}
return 3;
}
export function getAvailabilityLabel(statusCode: number | null | undefined): string {
if (statusCode === 1) {
return "Yes";
}
if (statusCode === 2) {
return "Maybe";
}
if (statusCode === 0) {
return "No";
}
return "Unknown";
}
export function orderMembersByLineupAndRsvps(
members: TeamSnapMember[],
lineupEntries: TeamSnapEventLineupEntry[],
availabilities: TeamSnapAvailability[],
): TeamSnapMember[] {
if (!members.length) {
return [];
}
const ordered: TeamSnapMember[] = [];
const seen = new Set<string>();
const availabilityByMemberId = new Map<string, TeamSnapAvailability>();
for (const availability of availabilities) {
const memberId = toId(availability.memberId);
if (memberId) {
availabilityByMemberId.set(memberId, availability);
}
}
const lineupMembers: TeamSnapMember[] = [];
const lineupMembersSeen = new Set<string>();
for (const entry of lineupEntries.slice().sort((left, right) => {
const leftSequence = toSequence(left.sequence);
const rightSequence = toSequence(right.sequence);
if (leftSequence !== rightSequence) {
return leftSequence - rightSequence;
}
return toId(left.id).localeCompare(toId(right.id));
})) {
const member = members.find((candidate) => matchesLineupEntry(candidate, entry));
if (!member) {
continue;
}
const memberId = toId(member.id);
if (lineupMembersSeen.has(memberId)) {
continue;
}
lineupMembers.push(member);
lineupMembersSeen.add(memberId);
}
for (const member of lineupMembers) {
const memberId = toId(member.id);
if (seen.has(memberId)) {
continue;
}
ordered.push(member);
seen.add(memberId);
}
const rankedMembers = members
.filter((member) => isPlayerMember(member))
.filter((member) => !seen.has(toId(member.id)))
.map((member, index) => {
const memberId = toId(member.id);
const availability = availabilityByMemberId.get(memberId);
return {
member,
rank: getAvailabilityRank(availability?.statusCode as number | null | undefined),
index,
};
})
.sort((left, right) => {
if (left.rank !== right.rank) {
return left.rank - right.rank;
}
const leftLastName = getMemberLastName(left.member).toLowerCase();
const rightLastName = getMemberLastName(right.member).toLowerCase();
if (leftLastName !== rightLastName) {
return leftLastName.localeCompare(rightLastName);
}
const leftFirstName = asDisplayText(left.member.firstName).toLowerCase();
const rightFirstName = asDisplayText(right.member.firstName).toLowerCase();
if (leftFirstName !== rightFirstName) {
return leftFirstName.localeCompare(rightFirstName);
}
return left.index - right.index;
})
;
for (const entry of rankedMembers) {
ordered.push(entry.member);
seen.add(toId(entry.member.id));
}
for (const member of members) {
const memberId = toId(member.id);
if (seen.has(memberId)) {
continue;
}
ordered.push(member);
}
return ordered;
}