Squash merge feature/library-reorganization
This commit is contained in:
26
frontend/src/lib/media.ts
Normal file
26
frontend/src/lib/media.ts
Normal 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`;
|
||||
}
|
||||
19
frontend/src/lib/offlinePrep.ts
Normal file
19
frontend/src/lib/offlinePrep.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
frontend/src/lib/queryClient.ts
Normal file
4
frontend/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
|
||||
186
frontend/src/lib/teamsnap.ts
Normal file
186
frontend/src/lib/teamsnap.ts
Normal 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: [] };
|
||||
}
|
||||
},
|
||||
};
|
||||
334
frontend/src/lib/teamsnapHelpers.ts
Normal file
334
frontend/src/lib/teamsnapHelpers.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user