diff --git a/PLAN.md b/PLAN.md index 8ee0ecd..846e8b7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -18,6 +18,7 @@ - Home page now acts as a lightweight landing page with direct links to Library and Gameday. - Removed the old game-list-heavy dashboard content that was not useful as a landing surface. - Game titles in the UI now include a day parenthetical such as `(sun 5/3)` wherever the shared formatter is used. +- TeamSnap gameday lineup reads now prefer the SDK `bulkLoad` path for `eventLineup` and `eventLineupEntry`, with rel-based fallback for accounts where bulk results are incomplete. ## Storage Status - Backend media persists in the `backend-media` named Docker volume. diff --git a/frontend/src/lib/teamsnap.ts b/frontend/src/lib/teamsnap.ts index 79a2bb9..52e2396 100644 --- a/frontend/src/lib/teamsnap.ts +++ b/frontend/src/lib/teamsnap.ts @@ -23,7 +23,7 @@ type TeamSnapSdk = { loadEventLineups?: (params: unknown) => Promise; loadAvailabilities?: (params: unknown) => Promise; loadAssignments?: (params: unknown) => Promise; - bulkLoad?: (teamId: string | number, typesOrParams?: unknown) => Promise; + bulkLoad?: (teamIdOrParams: string | number | Record, typesOrParams?: unknown) => Promise; createEventLineup?: (data?: Record) => unknown; saveEventLineup?: (eventLineup: unknown) => Promise | void; deleteEventLineup?: (eventLineup: unknown) => Promise | 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 | 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; @@ -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 { diff --git a/frontend/src/pages/GamedayPage.tsx b/frontend/src/pages/GamedayPage.tsx index 731c2f7..ffc9a29 100644 --- a/frontend/src/pages/GamedayPage.tsx +++ b/frontend/src/pages/GamedayPage.tsx @@ -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 (
@@ -336,9 +331,7 @@ export function GamedayPage() { {formatMemberName(member)} {jerseyNumber ? ` ${jerseyNumber}` : ""} - {lineupEntry ? Lineup {lineupEntry.sequence ?? "?"} : null}
-
{playerMeta.join(" • ")}
-
+
-
-
- Debug: Show raw lineup data -
-                            {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,
-                            )}
-                          
-
-
) : null} @@ -451,7 +423,7 @@ function LibraryClips({ }); return ( -
+
{clips.map((clip) => { const key = clipKey("library", clip.id); const isPlaying = playingClipKey === key; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index bbd1476..b64db74 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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;