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:
@@ -26,6 +26,11 @@
|
|||||||
let nextPageTime = $state(data.nextPageTime);
|
let nextPageTime = $state(data.nextPageTime);
|
||||||
let isLoadingMore = $state(false);
|
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)
|
// Reset pagination when data changes (new filters applied)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
matches = data.matches;
|
matches = data.matches;
|
||||||
@@ -33,6 +38,44 @@
|
|||||||
nextPageTime = data.nextPageTime;
|
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 handleSearch = () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (searchQuery) params.set('search', searchQuery);
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
@@ -120,18 +163,83 @@
|
|||||||
|
|
||||||
<!-- Filter Panel (Collapsible) -->
|
<!-- Filter Panel (Collapsible) -->
|
||||||
{#if showFilters}
|
{#if showFilters}
|
||||||
<div class="border-t border-base-300 pt-4">
|
<div class="space-y-4 border-t border-base-300 pt-4">
|
||||||
<h3 class="mb-3 font-semibold text-base-content">Filter by Map</h3>
|
<!-- Map Filter -->
|
||||||
<div class="flex flex-wrap gap-2">
|
<div>
|
||||||
{#each commonMaps as mapName}
|
<h3 class="mb-3 font-semibold text-base-content">Filter by Map</h3>
|
||||||
<a
|
<div class="flex flex-wrap gap-2">
|
||||||
href={`/matches?map=${mapName}`}
|
{#each commonMaps as mapName}
|
||||||
class="badge badge-outline badge-lg hover:badge-primary"
|
<a
|
||||||
class:badge-primary={currentMap === mapName}
|
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}
|
All Matches
|
||||||
</a>
|
</button>
|
||||||
{/each}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -155,10 +263,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Card>
|
</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 -->
|
<!-- Matches Grid -->
|
||||||
{#if matches.length > 0}
|
{#if displayMatches().length > 0}
|
||||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each matches as match}
|
{#each displayMatches() as match}
|
||||||
<MatchCard {match} />
|
<MatchCard {match} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,9 +297,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if matches.length > 0}
|
{:else if matches.length > 0}
|
||||||
<div class="mt-8 text-center">
|
<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>
|
</div>
|
||||||
{/if}
|
{/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}
|
{:else}
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
|
|||||||
Reference in New Issue
Block a user