space-game/src/components/leaderboard/Leaderboard.svelte
Michael Mainguy 123b341ed7 Replace debugLog and console.* with loglevel logger
- Create centralized logger module (src/core/logger.ts)
- Replace all debugLog() calls with log.debug()
- Replace console.log() with log.info()
- Replace console.warn() with log.warn()
- Replace console.error() with log.error()
- Delete deprecated src/core/debug.ts
- Configure log levels: debug for dev, warn for production
- Add localStorage override for production debugging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 05:24:18 -06:00

509 lines
14 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Link } from 'svelte-routing';
import { gameResultsStore } from '../../stores/gameResults';
import type { GameResult } from '../../services/gameResultsService';
import { CloudLeaderboardService, type CloudLeaderboardEntry, getDisplayName } from '../../services/cloudLeaderboardService';
import { formatStars } from '../../game/scoreCalculator';
import log from '../../core/logger';
// View toggle: 'local' or 'cloud'
let activeView: 'local' | 'cloud' = 'cloud';
let cloudResults: CloudLeaderboardEntry[] = [];
let cloudLoading = false;
let cloudLoadingMore = false;
let cloudError = '';
let hasMore = true;
const PAGE_SIZE = 20;
// Reference to the scroll container for infinite scroll
let scrollContainer: HTMLElement;
let sentinel: HTMLElement;
let observer: IntersectionObserver;
// Check if cloud is available
const cloudService = CloudLeaderboardService.getInstance();
const cloudAvailable = cloudService.isAvailable();
// Load cloud leaderboard (initial or more)
async function loadCloudLeaderboard(loadMore = false) {
if (loadMore) {
if (cloudLoadingMore || !hasMore) return;
cloudLoadingMore = true;
} else {
cloudLoading = true;
cloudResults = [];
hasMore = true;
}
cloudError = '';
try {
const offset = loadMore ? cloudResults.length : 0;
const newResults = await cloudService.getGlobalLeaderboard(PAGE_SIZE, offset);
if (loadMore) {
cloudResults = [...cloudResults, ...newResults];
} else {
cloudResults = newResults;
}
// If we got fewer results than requested, there are no more
if (newResults.length < PAGE_SIZE) {
hasMore = false;
}
} catch (error) {
cloudError = 'Failed to load cloud leaderboard';
log.error('[Leaderboard] Cloud load error:', error);
} finally {
cloudLoading = false;
cloudLoadingMore = false;
}
}
// Switch view
function setView(view: 'local' | 'cloud') {
activeView = view;
if (view === 'cloud' && cloudResults.length === 0 && !cloudLoading) {
loadCloudLeaderboard();
}
}
// Setup intersection observer for infinite scroll
function setupInfiniteScroll() {
// Disconnect previous observer if exists
if (observer) {
observer.disconnect();
}
if (!sentinel) return;
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && activeView === 'cloud' && hasMore && !cloudLoadingMore) {
loadCloudLeaderboard(true);
}
},
{ rootMargin: '200px' } // Trigger earlier for smoother experience
);
observer.observe(sentinel);
}
// Reactively setup observer when sentinel element is bound
$: if (sentinel && cloudResults.length > 0) {
setupInfiniteScroll();
}
// Refresh data on mount
onMount(() => {
gameResultsStore.refresh();
if (cloudAvailable && activeView === 'cloud') {
loadCloudLeaderboard();
}
});
onDestroy(() => {
if (observer) {
observer.disconnect();
}
});
// Format time as MM:SS
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Format date as readable string
function formatDate(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
// Get color for end reason badge
function getEndReasonColor(endReason: string): string {
if (endReason === 'victory') return '#4ade80';
if (endReason === 'death') return '#ef4444';
return '#f59e0b'; // stranded
}
// Normalize cloud entry to match local result shape for display
function normalizeCloudEntry(entry: CloudLeaderboardEntry): GameResult {
return {
id: entry.id,
timestamp: new Date(entry.created_at).getTime(),
playerName: getDisplayName(entry),
levelId: entry.level_id,
levelName: entry.level_name,
completed: entry.completed,
endReason: entry.end_reason as 'victory' | 'death' | 'stranded',
gameTimeSeconds: entry.game_time_seconds,
asteroidsDestroyed: entry.asteroids_destroyed,
totalAsteroids: entry.total_asteroids,
accuracy: entry.accuracy,
hullDamageTaken: entry.hull_damage_taken,
fuelConsumed: entry.fuel_consumed,
finalScore: entry.final_score,
starRating: entry.star_rating
};
}
// Get current results based on active view
$: displayResults = activeView === 'cloud'
? cloudResults.map(normalizeCloudEntry)
: $gameResultsStore;
</script>
<div class="editor-container">
<Link to="/" class="back-link">← Back to Game</Link>
<h1>Leaderboard</h1>
<p class="subtitle">Global High Scores</p>
<!-- View Toggle -->
{#if cloudAvailable}
<div class="view-toggle">
<button
class="toggle-btn"
class:active={activeView === 'cloud'}
on:click={() => setView('cloud')}
>
Global
</button>
<button
class="toggle-btn"
class:active={activeView === 'local'}
on:click={() => setView('local')}
>
Local
</button>
</div>
{/if}
<div class="leaderboard-wrapper">
{#if cloudLoading && activeView === 'cloud'}
<div class="no-results">
<p>Loading global leaderboard...</p>
</div>
{:else if cloudError && activeView === 'cloud'}
<div class="no-results">
<p>{cloudError}</p>
<button class="retry-btn" on:click={loadCloudLeaderboard}>Retry</button>
</div>
{:else if displayResults.length === 0}
<div class="no-results">
<p>No game results yet!</p>
<p class="muted">Play a level to see your scores here.</p>
</div>
{:else}
<table class="leaderboard-table">
<thead>
<tr>
<th class="rank-col">Rank</th>
<th class="player-col">Player</th>
<th class="level-col">Level</th>
<th class="score-col">Score</th>
<th class="stars-col">Stars</th>
<th class="result-col">Result</th>
<th class="time-col">Time</th>
<th class="date-col">Date</th>
</tr>
</thead>
<tbody>
{#each displayResults as result, i}
<tr class:victory={result.completed}>
<td class="rank-col">
<span class="rank-badge" class:gold={i === 0} class:silver={i === 1} class:bronze={i === 2}>
{i + 1}
</span>
</td>
<td class="player-col">{result.playerName}</td>
<td class="level-col">{result.levelName}</td>
<td class="score-col">
<span class="score-value">{result.finalScore.toLocaleString()}</span>
</td>
<td class="stars-col">
<span class="star-display">{formatStars(result.starRating)}</span>
<span class="star-count">{result.starRating}/12</span>
</td>
<td class="result-col">
<span class="result-badge" style="background-color: {getEndReasonColor(result.endReason)}">
{result.endReason}
</span>
</td>
<td class="time-col">{formatTime(result.gameTimeSeconds)}</td>
<td class="date-col">{formatDate(result.timestamp)}</td>
</tr>
{/each}
</tbody>
</table>
<!-- Infinite scroll sentinel and loading indicator -->
{#if activeView === 'cloud'}
<div bind:this={sentinel} class="scroll-sentinel">
{#if cloudLoadingMore}
<div class="loading-more">
<span class="spinner"></span>
Loading more...
</div>
{:else if !hasMore && cloudResults.length > 0}
<div class="end-of-list">
You've reached the end!
</div>
{/if}
</div>
{/if}
{/if}
</div>
<div class="leaderboard-footer">
<p class="muted">
{#if activeView === 'cloud'}
Showing {displayResults.length} global scores
{:else}
Showing {displayResults.length} local scores (this device only)
{/if}
</p>
</div>
</div>
<style>
.view-toggle {
display: flex;
justify-content: center;
gap: var(--space-sm, 8px);
margin-top: var(--space-lg, 24px);
}
.toggle-btn {
padding: var(--space-sm, 8px) var(--space-xl, 32px);
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
color: var(--color-text-secondary, #e8e8e8);
border-radius: var(--radius-md, 6px);
cursor: pointer;
font-size: var(--font-size-sm, 0.9rem);
transition: all var(--transition-fast, 0.2s ease);
}
.toggle-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.toggle-btn.active {
background: var(--color-primary, #4f46e5);
border-color: var(--color-primary, #4f46e5);
color: white;
}
.retry-btn {
margin-top: var(--space-md, 16px);
padding: var(--space-sm, 8px) var(--space-lg, 24px);
background: var(--color-primary, #4f46e5);
color: white;
border: none;
border-radius: var(--radius-md, 6px);
cursor: pointer;
font-size: var(--font-size-sm, 0.9rem);
}
.retry-btn:hover {
opacity: 0.9;
}
.leaderboard-wrapper {
background: var(--color-bg-card, rgba(20, 20, 40, 0.9));
border: 1px solid var(--color-border-default, rgba(255, 255, 255, 0.2));
border-radius: var(--radius-lg, 10px);
overflow-x: auto; /* Allow horizontal scroll on mobile, but not hidden */
margin-top: var(--space-xl, 32px);
}
.no-results {
text-align: center;
padding: var(--space-3xl, 64px) var(--space-xl, 32px);
color: var(--color-text-secondary, #e8e8e8);
}
.no-results .muted {
color: var(--color-text-muted, #aaaaaa);
margin-top: var(--space-md, 16px);
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm, 0.9rem);
}
.leaderboard-table thead {
background: rgba(0, 0, 0, 0.4);
}
.leaderboard-table th {
padding: var(--space-md, 16px) var(--space-sm, 8px);
text-align: left;
font-weight: bold;
color: var(--color-text-secondary, #e8e8e8);
text-transform: uppercase;
font-size: var(--font-size-xs, 0.8rem);
letter-spacing: 0.5px;
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
}
.leaderboard-table td {
padding: var(--space-md, 16px) var(--space-sm, 8px);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
vertical-align: middle;
}
.leaderboard-table tbody tr {
transition: background var(--transition-fast, 0.2s ease);
}
.leaderboard-table tbody tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.leaderboard-table tbody tr.victory {
background: rgba(74, 222, 128, 0.05);
}
.leaderboard-table tbody tr.victory:hover {
background: rgba(74, 222, 128, 0.1);
}
/* Column widths */
.rank-col { width: 60px; text-align: center; }
.player-col { min-width: 120px; }
.level-col { min-width: 140px; }
.score-col { width: 100px; text-align: right; }
.stars-col { width: 100px; text-align: center; }
.result-col { width: 100px; text-align: center; }
.time-col { width: 70px; text-align: center; }
.date-col { width: 100px; text-align: right; }
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
font-weight: bold;
font-size: var(--font-size-sm, 0.9rem);
}
.rank-badge.gold {
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #000;
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.5);
}
.rank-badge.silver {
background: linear-gradient(135deg, #C0C0C0, #A0A0A0);
color: #000;
box-shadow: 0 2px 8px rgba(192, 192, 192, 0.5);
}
.rank-badge.bronze {
background: linear-gradient(135deg, #CD7F32, #B87333);
color: #000;
box-shadow: 0 2px 8px rgba(205, 127, 50, 0.5);
}
.score-value {
color: #FFD700;
font-weight: bold;
font-size: var(--font-size-base, 1rem);
}
.star-display {
display: block;
font-size: var(--font-size-sm, 0.9rem);
color: #FFD700;
}
.star-count {
display: block;
font-size: var(--font-size-xs, 0.8rem);
color: var(--color-text-muted, #aaaaaa);
}
.result-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: var(--font-size-xs, 0.8rem);
font-weight: bold;
text-transform: uppercase;
color: #000;
}
.leaderboard-footer {
text-align: center;
padding: var(--space-lg, 24px);
}
.leaderboard-footer .muted {
color: var(--color-text-muted, #aaaaaa);
font-size: var(--font-size-sm, 0.9rem);
}
/* Infinite scroll */
.scroll-sentinel {
padding: var(--space-lg, 24px);
text-align: center;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm, 8px);
color: var(--color-text-secondary, #e8e8e8);
font-size: var(--font-size-sm, 0.9rem);
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: var(--color-primary, #4f46e5);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.end-of-list {
color: var(--color-text-muted, #aaaaaa);
font-size: var(--font-size-sm, 0.9rem);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.leaderboard-table {
display: block;
overflow-x: auto;
}
.leaderboard-table th,
.leaderboard-table td {
padding: var(--space-sm, 8px) var(--space-xs, 4px);
}
.level-col, .date-col {
display: none;
}
}
</style>