feat: Add sorting and result filtering to matches page

Adds client-side sorting and filtering capabilities:

Sorting options:
- Date (newest/oldest)
- Duration (shortest/longest)
- Score difference (close games/blowouts)
- Toggle ascending/descending order

Result filters:
- All matches
- Wins only (Team A won)
- Losses only (Team B won)
- Ties only

Features:
- Reactive $derived computed matches list
- Shows filtered count vs total matches
- Empty state when no matches match filters
- Clear filter button when results are empty
- Works seamlessly with pagination (Load More)

Completes Phase 5.2 advanced features from TODO.md.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-04 23:45:21 +01:00
parent 8093d4d308
commit 7d8e3a6de0

View File

@@ -26,6 +26,11 @@
let nextPageTime = $state(data.nextPageTime);
let isLoadingMore = $state(false);
// Sorting and filtering state
let sortBy = $state<'date' | 'duration' | 'score'>('date');
let sortOrder = $state<'desc' | 'asc'>('desc');
let resultFilter = $state<'all' | 'win' | 'loss' | 'tie'>('all');
// Reset pagination when data changes (new filters applied)
$effect(() => {
matches = data.matches;
@@ -33,6 +38,44 @@
nextPageTime = data.nextPageTime;
});
// Computed filtered and sorted matches
const displayMatches = $derived(() => {
let filtered = [...matches];
// Apply result filter
if (resultFilter !== 'all') {
filtered = filtered.filter((match) => {
const teamAWon = match.score_team_a > match.score_team_b;
const teamBWon = match.score_team_b > match.score_team_a;
const tie = match.score_team_a === match.score_team_b;
if (resultFilter === 'win') return teamAWon;
if (resultFilter === 'loss') return teamBWon;
if (resultFilter === 'tie') return tie;
return true;
});
}
// Apply sorting
filtered.sort((a, b) => {
let comparison = 0;
if (sortBy === 'date') {
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
} else if (sortBy === 'duration') {
comparison = a.duration - b.duration;
} else if (sortBy === 'score') {
const aScoreDiff = Math.abs(a.score_team_a - a.score_team_b);
const bScoreDiff = Math.abs(b.score_team_a - b.score_team_b);
comparison = aScoreDiff - bScoreDiff;
}
return sortOrder === 'desc' ? -comparison : comparison;
});
return filtered;
});
const handleSearch = () => {
const params = new URLSearchParams();
if (searchQuery) params.set('search', searchQuery);
@@ -120,18 +163,83 @@
<!-- Filter Panel (Collapsible) -->
{#if showFilters}
<div class="border-t border-base-300 pt-4">
<h3 class="mb-3 font-semibold text-base-content">Filter by Map</h3>
<div class="flex flex-wrap gap-2">
{#each commonMaps as mapName}
<a
href={`/matches?map=${mapName}`}
class="badge badge-outline badge-lg hover:badge-primary"
class:badge-primary={currentMap === mapName}
<div class="space-y-4 border-t border-base-300 pt-4">
<!-- Map Filter -->
<div>
<h3 class="mb-3 font-semibold text-base-content">Filter by Map</h3>
<div class="flex flex-wrap gap-2">
{#each commonMaps as mapName}
<a
href={`/matches?map=${mapName}`}
class="badge badge-outline badge-lg hover:badge-primary"
class:badge-primary={currentMap === mapName}
>
{mapName}
</a>
{/each}
</div>
</div>
<!-- Result Filter -->
<div>
<h3 class="mb-3 font-semibold text-base-content">Filter by Result</h3>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="badge badge-lg"
class:badge-primary={resultFilter === 'all'}
class:badge-outline={resultFilter !== 'all'}
onclick={() => (resultFilter = 'all')}
>
{mapName}
</a>
{/each}
All Matches
</button>
<button
type="button"
class="badge badge-lg"
class:badge-success={resultFilter === 'win'}
class:badge-outline={resultFilter !== 'win'}
onclick={() => (resultFilter = 'win')}
>
Wins
</button>
<button
type="button"
class="badge badge-lg"
class:badge-error={resultFilter === 'loss'}
class:badge-outline={resultFilter !== 'loss'}
onclick={() => (resultFilter = 'loss')}
>
Losses
</button>
<button
type="button"
class="badge badge-lg"
class:badge-warning={resultFilter === 'tie'}
class:badge-outline={resultFilter !== 'tie'}
onclick={() => (resultFilter = 'tie')}
>
Ties
</button>
</div>
</div>
<!-- Sort Controls -->
<div>
<h3 class="mb-3 font-semibold text-base-content">Sort By</h3>
<div class="flex flex-wrap gap-2">
<select bind:value={sortBy} class="select select-bordered select-sm">
<option value="date">Date</option>
<option value="duration">Duration</option>
<option value="score">Score Difference</option>
</select>
<button
type="button"
class="btn btn-sm"
onclick={() => (sortOrder = sortOrder === 'desc' ? 'asc' : 'desc')}
>
{sortOrder === 'desc' ? '↓ Descending' : '↑ Ascending'}
</button>
</div>
</div>
</div>
{/if}
@@ -155,10 +263,19 @@
{/if}
</Card>
<!-- Results Summary -->
{#if matches.length > 0 && resultFilter !== 'all'}
<div class="mb-4">
<Badge variant="info">
Showing {displayMatches().length} of {matches.length} matches
</Badge>
</div>
{/if}
<!-- Matches Grid -->
{#if matches.length > 0}
{#if displayMatches().length > 0}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each matches as match}
{#each displayMatches() as match}
<MatchCard {match} />
{/each}
</div>
@@ -180,9 +297,28 @@
</div>
{:else if matches.length > 0}
<div class="mt-8 text-center">
<Badge variant="default">All matches loaded ({matches.length} total)</Badge>
<Badge variant="default">
All matches loaded ({matches.length} total{resultFilter !== 'all'
? `, ${displayMatches().length} shown`
: ''})
</Badge>
</div>
{/if}
{:else if matches.length > 0 && displayMatches().length === 0}
<Card padding="lg">
<div class="text-center">
<Calendar class="mx-auto mb-4 h-16 w-16 text-base-content/40" />
<h2 class="mb-2 text-xl font-semibold text-base-content">No Matches Found</h2>
<p class="text-base-content/60">
No matches match your current filters. Try adjusting your filter settings.
</p>
<div class="mt-4">
<Button variant="primary" onclick={() => (resultFilter = 'all')}>
Clear Result Filter
</Button>
</div>
</div>
</Card>
{:else}
<Card padding="lg">
<div class="text-center">