From 0d404a8d84e4fefaf7c12e8a472e3634da53928b Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 29 Oct 2025 23:26:40 -0700 Subject: [PATCH] Polish inputs and make re-usable components --- src/lib/components/PlayerCompareForm.svelte | 228 +++++++++++--- src/lib/utils/plebsaber-utils.ts | 25 +- src/routes/tools/+page.svelte | 4 +- .../tools/compare-histories/+page.svelte | 153 +--------- .../+page.svelte | 46 ++- .../+page.svelte | 285 +++++++++++++----- 6 files changed, 461 insertions(+), 280 deletions(-) rename src/routes/tools/{beatleader-headtohead => player-headtohead}/+page.svelte (94%) rename src/routes/tools/{beatleader-playlist-gap => player-playlist-gaps}/+page.svelte (66%) diff --git a/src/lib/components/PlayerCompareForm.svelte b/src/lib/components/PlayerCompareForm.svelte index 0f24df7..498eda4 100644 --- a/src/lib/components/PlayerCompareForm.svelte +++ b/src/lib/components/PlayerCompareForm.svelte @@ -9,6 +9,7 @@ export let loading = false; export let hasResults = false; export let oncompare: (() => void) | undefined = undefined; + export let currentPlayer: BeatLeaderPlayerProfile | null = null; let initialized = false; @@ -17,13 +18,36 @@ let playerAProfile: BeatLeaderPlayerProfile | null = null; let playerBProfile: BeatLeaderPlayerProfile | null = null; + // Preview profiles (loaded as user types) + let previewAProfile: BeatLeaderPlayerProfile | null = null; + let previewBProfile: BeatLeaderPlayerProfile | null = null; + let loadingPreviewA = false; + let loadingPreviewB = false; + // Load from URL params on mount onMount(() => { if (browser) { const sp = new URLSearchParams(location.search); - playerA = sp.get('a') ?? playerA; - playerB = sp.get('b') ?? playerB; + const urlA = sp.get('a'); + const urlB = sp.get('b'); + + // Prefill playerA with current player if not in URL + if (!urlA && !playerA && currentPlayer?.id) { + playerA = currentPlayer.id; + } else { + playerA = urlA ?? playerA; + } + + playerB = urlB ?? playerB; initialized = true; + + // Load initial previews if IDs are already present + if (playerA.trim()) { + void loadPreviewProfile('A', playerA.trim()); + } + if (playerB.trim()) { + void loadPreviewProfile('B', playerB.trim()); + } } }); @@ -67,6 +91,88 @@ playerBProfile = pb; } + // Debounced preview loading + let previewDebounceTimerA: ReturnType | null = null; + let previewDebounceTimerB: ReturnType | null = null; + + async function loadPreviewProfile(player: 'A' | 'B', id: string): Promise { + if (!id || id.trim().length < 3) { + if (player === 'A') previewAProfile = null; + else previewBProfile = null; + return; + } + + const trimmed = id.trim(); + if (player === 'A') { + loadingPreviewA = true; + } else { + loadingPreviewB = true; + } + + try { + const profile = await fetchPlayerProfile(trimmed); + // Only update if this is still the current player ID + const currentId = player === 'A' ? playerA.trim() : playerB.trim(); + if (currentId === trimmed) { + if (player === 'A') { + previewAProfile = profile; + } else { + previewBProfile = profile; + } + } + } catch { + // Silently fail - don't show errors for preview loading + const currentId = player === 'A' ? playerA.trim() : playerB.trim(); + if (currentId === trimmed) { + if (player === 'A') { + previewAProfile = null; + } else { + previewBProfile = null; + } + } + } finally { + if (player === 'A') { + loadingPreviewA = false; + } else { + loadingPreviewB = false; + } + } + } + + // Watch for changes to playerA and debounce preview loading + $: if (initialized && browser) { + const trimmed = playerA.trim(); + if (previewDebounceTimerA) clearTimeout(previewDebounceTimerA); + + if (trimmed && trimmed.length >= 3) { + previewDebounceTimerA = setTimeout(() => { + void loadPreviewProfile('A', trimmed); + }, 800); + } else { + previewAProfile = null; + } + } + + // Watch for changes to playerB and debounce preview loading + $: if (initialized && browser) { + const trimmed = playerB.trim(); + if (previewDebounceTimerB) clearTimeout(previewDebounceTimerB); + + if (trimmed && trimmed.length >= 3) { + previewDebounceTimerB = setTimeout(() => { + void loadPreviewProfile('B', trimmed); + }, 800); + } else { + previewBProfile = null; + } + } + + // Computed: which profile to display (post-compare takes priority over preview) + $: displayProfileA = hasCompared ? playerAProfile : previewAProfile; + $: displayProfileB = hasCompared ? playerBProfile : previewBProfile; + $: showCardA = !!(displayProfileA || (loadingPreviewA && playerA.trim().length >= 3)); + $: showCardB = !!(displayProfileB || (loadingPreviewB && playerB.trim().length >= 3)); + async function handleSubmit(e: Event) { e.preventDefault(); hasCompared = true; @@ -90,31 +196,33 @@ required /> + {#if $$slots['player-a-card']} + + {:else if showCardA} +
+ {#if displayProfileA} + + {:else if loadingPreviewA} +
Loading player...
+ {:else if hasCompared} +
Player A profile not found
+ {/if} +
+ {/if} - {#if $$slots['player-a-card']} - - {:else if hasCompared} -
- {#if playerAProfile} - - {:else} -
Player A profile not found
- {/if} -
- {/if}
@@ -127,31 +235,33 @@ required /> + {#if $$slots['player-b-card']} + + {:else if showCardB} +
+ {#if displayProfileB} + + {:else if loadingPreviewB} +
Loading player...
+ {:else if hasCompared} +
Player B profile not found
+ {/if} +
+ {/if}
- {#if $$slots['player-b-card']} - - {:else if hasCompared} -
- {#if playerBProfile} - - {:else} -
Player B profile not found
- {/if} -
- {/if} @@ -224,5 +334,23 @@ box-shadow: 0 0 20px rgba(34, 211, 238, 0.35), 0 0 0 2px rgba(34, 211, 238, 0.1); color: rgba(255, 255, 255, 1); } + + .player-card-wrapper { + margin-top: 1rem; + } + + .empty-profile { + padding: 2rem; + text-align: center; + color: rgba(148, 163, 184, 0.7); + font-size: 0.9rem; + } + + .loading-profile { + padding: 2rem; + text-align: center; + color: rgba(148, 163, 184, 0.7); + font-size: 0.9rem; + } diff --git a/src/lib/utils/plebsaber-utils.ts b/src/lib/utils/plebsaber-utils.ts index d483c28..7d42043 100644 --- a/src/lib/utils/plebsaber-utils.ts +++ b/src/lib/utils/plebsaber-utils.ts @@ -104,8 +104,8 @@ const DEFAULT_PRIVATE_TOOL_REQUIREMENT: ToolRequirement = { export const TOOL_REQUIREMENTS = { 'compare-histories': DEFAULT_PRIVATE_TOOL_REQUIREMENT, - 'beatleader-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT, - 'beatleader-playlist-gap': DEFAULT_PRIVATE_TOOL_REQUIREMENT + 'player-headtohead': DEFAULT_PRIVATE_TOOL_REQUIREMENT, + 'player-playlist-gaps': DEFAULT_PRIVATE_TOOL_REQUIREMENT } as const satisfies Record; export type ToolKey = keyof typeof TOOL_REQUIREMENTS; @@ -320,6 +320,27 @@ export async function fetchAllRecentScoresAllDiffs( return Array.from(merged.values()); } +/** + * Fetch all scores for a player (no time limit) by paginating through the BeatLeader API + * Useful for playlist gap analysis where we need to check all historical plays + */ +export async function fetchAllPlayerScores(playerId: string, maxPages = 200): Promise { + const pageSize = 100; + let page = 1; + const all: BeatLeaderScore[] = []; + while (page <= maxPages) { + const url = `/api/beatleader/player/${encodeURIComponent(playerId)}?scores=1&count=${pageSize}&page=${page}&sortBy=date&order=desc`; + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`); + const data = (await res.json()) as BeatLeaderScoresResponse; + const batch = data.data ?? []; + if (batch.length === 0) break; + all.push(...batch); + page += 1; + } + return all; +} + // ============================================================================ // 4. Data Processing Helpers // ============================================================================ diff --git a/src/routes/tools/+page.svelte b/src/routes/tools/+page.svelte index 99268ac..e2b91bf 100644 --- a/src/routes/tools/+page.svelte +++ b/src/routes/tools/+page.svelte @@ -5,8 +5,8 @@
{#each [ { name: 'Compare Play Histories', href: '/tools/compare-histories', desc: 'Find songs A played that B has not' }, - { name: 'BeatLeader Playlist Gap', href: '/tools/beatleader-playlist-gap', desc: 'Upload a playlist and find songs a player has not played' }, - { name: 'BeatLeader Head-to-Head', href: '/tools/beatleader-headtohead', desc: 'Compare two players on the same map/difficulty' } + { name: 'Player Playlist Gaps', href: '/tools/player-playlist-gaps', desc: 'Upload a playlist and find songs a player has not played' }, + { name: 'Player Head-to-Head', href: '/tools/player-headtohead', desc: 'Compare two players on the same map/difficulty' } ] as tool}
{tool.name}
diff --git a/src/routes/tools/compare-histories/+page.svelte b/src/routes/tools/compare-histories/+page.svelte index b5ae47e..e8bf3ba 100644 --- a/src/routes/tools/compare-histories/+page.svelte +++ b/src/routes/tools/compare-histories/+page.svelte @@ -1,13 +1,11 @@
-

BeatLeader: Head-to-Head

+

Player Head-to-Head

Paginated head-to-head results on the same map and difficulty.

- 0} oncompare={onCompare} /> + 0} oncompare={onCompare} currentPlayer={playerProfile} /> {#if errorMsg}
{errorMsg}
@@ -435,8 +455,10 @@ Options:
-

BeatLeader: Playlist Gap

+

Player Playlist Gaps

Upload a .bplist and enter a player ID to find songs they have not played.

-
-
- - {#if selectedFileName} -
{selectedFileName}{#if parsedTitle} · title: {parsedTitle}{/if} · {playlistSongs.length} songs
+ {#if !hasAnalyzed} + +
+
+ + {#if selectedFileName} +
{selectedFileName}{#if parsedTitle} · title: {parsedTitle}{/if} · {playlistSongs.length} songs
+ {/if} +
+
+ + {#if showPlayerCard} +
+ {#if displayProfile} + + {:else} +
Loading player...
+ {/if} +
+ {/if} +
+
+
+ +
+ + {:else} +
+ {#if displayProfile} +
+ +
{/if} -
-
- -
-
-
- + {/if} {#if errorMsg}
{errorMsg}
@@ -442,7 +548,7 @@ target="_blank" rel="noopener" title="Open in BeatSaver" - >BSR
+ >BS