Refine TeamSnap lineup reading and gameday UI
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user