Refine TeamSnap lineup reading and gameday UI

This commit is contained in:
Codex
2026-04-23 16:36:34 -05:00
parent b65113c8f3
commit 699970198a
4 changed files with 91 additions and 53 deletions

View File

@@ -23,7 +23,7 @@ type TeamSnapSdk = {
loadEventLineups?: (params: unknown) => Promise<TeamSnapEventLineup[]>;
loadAvailabilities?: (params: unknown) => Promise<TeamSnapAvailability[]>;
loadAssignments?: (params: unknown) => Promise<TeamSnapAssignment[]>;
bulkLoad?: (teamId: string | number, typesOrParams?: unknown) => Promise<TeamSnapBulkItem[]>;
bulkLoad?: (teamIdOrParams: string | number | Record<string, unknown>, typesOrParams?: unknown) => Promise<TeamSnapBulkItem[]>;
createEventLineup?: (data?: Record<string, unknown>) => unknown;
saveEventLineup?: (eventLineup: unknown) => Promise<unknown> | void;
deleteEventLineup?: (eventLineup: unknown) => Promise<unknown> | void;
@@ -45,6 +45,58 @@ type TeamSnapBulkItem = {
[key: string]: unknown;
};
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 isEventLineup(item: unknown): item is TeamSnapEventLineup {
return Boolean(item) && typeof item === "object" && (item as TeamSnapBulkItem).type === "eventLineup";
}
function isEventLineupEntry(item: unknown): item is TeamSnapEventLineupEntry {
return Boolean(item) && typeof item === "object" && (item as TeamSnapBulkItem).type === "eventLineupEntry";
}
function sortLineupEntries(entries: TeamSnapEventLineupEntry[]): TeamSnapEventLineupEntry[] {
return entries.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));
});
}
function normalizeBulkLineupData(
items: TeamSnapBulkItem[],
eventId: string,
): {
eventLineup: TeamSnapEventLineup | null;
entries: TeamSnapEventLineupEntry[];
} {
const eventLineups = items.filter(isEventLineup).filter((item) => toId(item.eventId) === eventId);
const eventLineup = eventLineups.length ? eventLineups[eventLineups.length - 1] : null;
const lineupId = toId(eventLineup?.id);
const entries = items
.filter(isEventLineupEntry)
.filter((item) => !lineupId || toId(item.eventLineupId) === lineupId);
return {
eventLineup,
entries: sortLineupEntries(entries),
};
}
let sdkPromise: Promise<TeamSnapSdk> | null = null;
declare global {
@@ -154,12 +206,31 @@ export const teamsnapClient = {
entries: TeamSnapEventLineupEntry[];
}> {
const sdk = await ensureAuthorized();
if (sdk.bulkLoad) {
try {
const bulkItems = await sdk.bulkLoad({
teamId,
types: ["eventLineup", "eventLineupEntry"],
scopeTo: "event",
event__id: eventId,
});
const lineupData = normalizeBulkLineupData(bulkItems, eventId);
if (lineupData.eventLineup || lineupData.entries.length) {
return lineupData;
}
} catch {
// Fall back to rel-driven reads when bulk load does not return lineup data.
}
}
if (!sdk.loadEventLineups) {
return { eventLineup: null, entries: [] };
}
const eventLineups = await sdk.loadEventLineups(eventId);
const eventLineup = eventLineups.length ? eventLineups[eventLineups.length - 1] : null;
const eventLineups = await sdk.loadEventLineups({ eventId });
const matchingEventLineups = eventLineups.filter((item) => toId(item.eventId) === eventId);
const eventLineup = matchingEventLineups.length ? matchingEventLineups[matchingEventLineups.length - 1] : null;
const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & {
loadItems?: (linkName: string) => Promise<TeamSnapEventLineupEntry[]>;
@@ -170,13 +241,12 @@ export const teamsnapClient = {
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;
});
const lineupId = toId(eventLineupWithLinks.id);
const entries = sortLineupEntries(
rawEntries
.filter(isEventLineupEntry)
.filter((item) => toId(item.eventLineupId) === lineupId),
);
return { eventLineup, entries };
} catch {

View File

@@ -14,7 +14,6 @@ import {
formatGameTitle,
formatMemberName,
formatMemberJerseyNumber,
findLineupEntryForMember,
isPlayerMember,
orderMembersByLineupAndRsvps,
} from "../lib/teamsnapHelpers";
@@ -300,16 +299,12 @@ export function GamedayPage() {
{visibleMembers.map((member) => {
const memberId = String(member.id);
const jerseyNumber = formatMemberJerseyNumber(member);
const lineupEntry = findLineupEntryForMember(member, eventLineupQuery.data?.entries ?? []);
const availability = (availabilityQuery.data ?? []).find(
(entry) => String(entry.memberId) === memberId,
) ?? null;
const isExpanded = memberId === expandedPlayerId;
const expansionId = `player-clips-${memberId}`;
const availabilityStatusCode = availability?.statusCode ?? null;
const playerMeta = [
lineupEntry?.label ?? null,
].filter(Boolean);
return (
<div className={`list-group-item p-0${isExpanded ? " bg-body-tertiary" : ""}`} key={memberId}>
@@ -336,9 +331,7 @@ export function GamedayPage() {
{formatMemberName(member)}
{jerseyNumber ? ` ${jerseyNumber}` : ""}
</strong>
{lineupEntry ? <span className="badge rounded-pill text-bg-secondary">Lineup {lineupEntry.sequence ?? "?"}</span> : null}
</div>
<div className="text-body-secondary small text-truncate">{playerMeta.join(" • ")}</div>
</div>
<i
className="bi bi-chevron-down ms-auto fs-5 lh-1"
@@ -353,7 +346,7 @@ export function GamedayPage() {
role="region"
aria-labelledby={`player-${memberId}-toggle`}
>
<div className="p-3 pb-0">
<div className="p-3 pb-3">
<div className="list-group list-group-flush">
<LibraryClips
teamId={teamId}
@@ -377,27 +370,6 @@ export function GamedayPage() {
/>
</div>
</div>
<div className="p-3 pt-2">
<details className="text-body-secondary">
<summary>Debug: Show raw lineup data</summary>
<pre className="mt-2 mb-0 rounded-3 bg-body-tertiary p-3 small text-body-secondary overflow-auto">
{JSON.stringify(
{
expandedPlayerId,
selectedPlayerName: formatMemberName(member),
rawEventLineup: eventLineupQuery.data?.eventLineup ?? null,
rawEventLineupEntries: eventLineupQuery.data?.entries ?? [],
matchedLineupEntry: lineupEntry,
matchedLineupEntryCount: (eventLineupQuery.data?.entries ?? []).filter(
(entry) => String(entry.memberId) === memberId,
).length,
},
null,
2,
)}
</pre>
</details>
</div>
</div>
) : null}
</div>
@@ -451,7 +423,7 @@ function LibraryClips({
});
return (
<div className="stack">
<div className="stack gameday-clip-list">
{clips.map((clip) => {
const key = clipKey("library", clip.id);
const isPlaying = playingClipKey === key;

View File

@@ -325,6 +325,14 @@ select {
gap: 0.75rem;
}
.gameday-clip-list {
gap: 0.4rem;
}
.gameday-clip-list .clip-summary {
padding: 0.6rem 0;
}
.clip-summary {
display: grid;
gap: 0.35rem;
@@ -946,19 +954,6 @@ select {
flex: 0 0 auto;
}
.gameday-debug {
margin: 0;
padding: 0.75rem 0.85rem;
border-radius: 0.75rem;
background: rgba(19, 34, 56, 0.06);
color: var(--ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.82rem;
line-height: 1.45;
white-space: pre-wrap;
overflow-x: auto;
}
@media (max-width: 900px) {
.gameday-toolbar {
left: 1rem;