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

@@ -18,6 +18,7 @@
- Home page now acts as a lightweight landing page with direct links to Library and Gameday. - 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. - 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. - 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 ## Storage Status
- Backend media persists in the `backend-media` named Docker volume. - Backend media persists in the `backend-media` named Docker volume.

View File

@@ -23,7 +23,7 @@ type TeamSnapSdk = {
loadEventLineups?: (params: unknown) => Promise<TeamSnapEventLineup[]>; loadEventLineups?: (params: unknown) => Promise<TeamSnapEventLineup[]>;
loadAvailabilities?: (params: unknown) => Promise<TeamSnapAvailability[]>; loadAvailabilities?: (params: unknown) => Promise<TeamSnapAvailability[]>;
loadAssignments?: (params: unknown) => Promise<TeamSnapAssignment[]>; 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; createEventLineup?: (data?: Record<string, unknown>) => unknown;
saveEventLineup?: (eventLineup: unknown) => Promise<unknown> | void; saveEventLineup?: (eventLineup: unknown) => Promise<unknown> | void;
deleteEventLineup?: (eventLineup: unknown) => Promise<unknown> | void; deleteEventLineup?: (eventLineup: unknown) => Promise<unknown> | void;
@@ -45,6 +45,58 @@ type TeamSnapBulkItem = {
[key: string]: unknown; [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; let sdkPromise: Promise<TeamSnapSdk> | null = null;
declare global { declare global {
@@ -154,12 +206,31 @@ export const teamsnapClient = {
entries: TeamSnapEventLineupEntry[]; entries: TeamSnapEventLineupEntry[];
}> { }> {
const sdk = await ensureAuthorized(); 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) { if (!sdk.loadEventLineups) {
return { eventLineup: null, entries: [] }; return { eventLineup: null, entries: [] };
} }
const eventLineups = await sdk.loadEventLineups(eventId); const eventLineups = await sdk.loadEventLineups({ eventId });
const eventLineup = eventLineups.length ? eventLineups[eventLineups.length - 1] : null; const matchingEventLineups = eventLineups.filter((item) => toId(item.eventId) === eventId);
const eventLineup = matchingEventLineups.length ? matchingEventLineups[matchingEventLineups.length - 1] : null;
const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & { const eventLineupWithLinks = eventLineup as TeamSnapEventLineup & {
loadItems?: (linkName: string) => Promise<TeamSnapEventLineupEntry[]>; loadItems?: (linkName: string) => Promise<TeamSnapEventLineupEntry[]>;
@@ -170,13 +241,12 @@ export const teamsnapClient = {
try { try {
const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries"); const rawEntries = await eventLineupWithLinks.loadItems("eventLineupEntries");
const entries = rawEntries const lineupId = toId(eventLineupWithLinks.id);
.filter((item): item is TeamSnapEventLineupEntry => item.type === "eventLineupEntry") const entries = sortLineupEntries(
.sort((left, right) => { rawEntries
const leftSequence = Number(left.sequence ?? Number.MAX_SAFE_INTEGER); .filter(isEventLineupEntry)
const rightSequence = Number(right.sequence ?? Number.MAX_SAFE_INTEGER); .filter((item) => toId(item.eventLineupId) === lineupId),
return leftSequence - rightSequence; );
});
return { eventLineup, entries }; return { eventLineup, entries };
} catch { } catch {

View File

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

View File

@@ -325,6 +325,14 @@ select {
gap: 0.75rem; gap: 0.75rem;
} }
.gameday-clip-list {
gap: 0.4rem;
}
.gameday-clip-list .clip-summary {
padding: 0.6rem 0;
}
.clip-summary { .clip-summary {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
@@ -946,19 +954,6 @@ select {
flex: 0 0 auto; 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) { @media (max-width: 900px) {
.gameday-toolbar { .gameday-toolbar {
left: 1rem; left: 1rem;