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