feat(#60): Add stats and filters to pokedex page

This commit is contained in:
Josh Creek
2026-01-09 21:51:21 +00:00
parent bd3f266a4a
commit 8fa394f102
@@ -20,6 +20,87 @@
export let createCatchRecords = () => {}; export let createCatchRecords = () => {};
export let onPokemonClick: (pokemon: CombinedData) => void = () => {}; export let onPokemonClick: (pokemon: CombinedData) => void = () => {};
let filterNotCaught = false;
let filterNeedsToEvolve = false;
let filterInHome = false;
let filterNotInHome = false;
let filteredCombinedData: CombinedData[] = [];
let filteredTotal = 0;
let overallTotal = 0;
let overallCaughtCount = 0;
let overallNotCaughtCount = 0;
let overallNeedsToEvolveCount = 0;
let overallInHomeCount = 0;
let overallNotInHomeCount = 0;
let filtersActive = false;
let filtersKey = '';
function normalizedStatus(catchRecord: CatchRecord | null) {
return {
caught: !!catchRecord?.caught,
needsToEvolve: !!catchRecord?.haveToEvolve,
inHome: !!catchRecord?.inHome
};
}
function matchesFilters(catchRecord: CatchRecord | null) {
const status = normalizedStatus(catchRecord);
if (!filtersActive) return true;
const isCaught = status.caught || status.needsToEvolve;
const isNotCaught = !isCaught;
const isNeedsToEvolve = status.needsToEvolve;
const isInHome = status.inHome;
const isNotInHome = !status.inHome;
// OR within each group, AND between groups.
const progressGroupActive = filterNotCaught || filterNeedsToEvolve;
const homeGroupActive = filterInHome || filterNotInHome;
const progressMatch =
!progressGroupActive ||
(filterNotCaught && isNotCaught) ||
(filterNeedsToEvolve && isNeedsToEvolve);
const homeMatch =
!homeGroupActive || (filterInHome && isInHome) || (filterNotInHome && isNotInHome);
return progressMatch && homeMatch;
}
$: filtersActive = filterNotCaught || filterNeedsToEvolve || filterInHome || filterNotInHome;
$: filtersKey = `${filterNotCaught}-${filterNeedsToEvolve}-${filterInHome}-${filterNotInHome}`;
$: {
// Ensure this recalculates when any filter changes (Svelte doesn't track function internals).
filtersKey;
filteredCombinedData = combinedData
? combinedData.filter(({ catchRecord }) => matchesFilters(catchRecord))
: [];
}
$: filteredTotal = filteredCombinedData.length;
$: overallTotal = combinedData?.length ?? 0;
$: overallCaughtCount = (combinedData ?? []).reduce((acc, { catchRecord }) => {
const status = normalizedStatus(catchRecord);
// Treat "needs to evolve" as a subset of "caught" (it is caught, just not finished).
return acc + (status.caught || status.needsToEvolve ? 1 : 0);
}, 0);
$: overallNotCaughtCount = overallTotal - overallCaughtCount;
$: overallNeedsToEvolveCount = (combinedData ?? []).reduce((acc, { catchRecord }) => {
return acc + (normalizedStatus(catchRecord).needsToEvolve ? 1 : 0);
}, 0);
$: overallInHomeCount = (combinedData ?? []).reduce((acc, { catchRecord }) => {
return acc + (normalizedStatus(catchRecord).inHome ? 1 : 0);
}, 0);
$: overallNotInHomeCount = overallTotal - overallInHomeCount;
const BOX_VIEW_LAYOUT_STORAGE_KEY = 'livingdex:boxViewLayout:v1'; const BOX_VIEW_LAYOUT_STORAGE_KEY = 'livingdex:boxViewLayout:v1';
type BoxViewLayout = 'comfortable' | 'compact' | 'ultra'; type BoxViewLayout = 'comfortable' | 'compact' | 'ultra';
let boxViewLayout: BoxViewLayout = 'comfortable'; let boxViewLayout: BoxViewLayout = 'comfortable';
@@ -89,86 +170,162 @@
} }
} }
} }
function onInHomeFilterChange(event: Event) {
const checked = (event.currentTarget as HTMLInputElement).checked;
if (checked) filterNotInHome = false;
}
function onNotInHomeFilterChange(event: Event) {
const checked = (event.currentTarget as HTMLInputElement).checked;
if (checked) filterInHome = false;
}
</script> </script>
<main class="flex-1 p-4 w-full"> <main class="flex-1 p-4 w-full">
<div class="max-w-fit mx-auto"> <div class="max-w-fit mx-auto">
{#if combinedData && combinedData.length > 0} {#if combinedData && combinedData.length > 0}
<div class="container mx-auto"> <div class="container mx-auto">
<div class="flex flex-col gap-3 mb-4"> <div class="card bg-base-100 shadow mb-4">
<div class="flex flex-wrap items-center gap-3"> <div class="card-body p-4 flex flex-col gap-3">
<label class="label p-0" for="box-view-layout"> <div class="flex flex-wrap items-center gap-3">
<span class="label-text font-semibold">Box view layout</span> <label class="label p-0" for="box-view-layout">
</label> <span class="label-text font-semibold">Box view layout</span>
<select </label>
id="box-view-layout" <select
class="select select-bordered select-sm" id="box-view-layout"
bind:value={boxViewLayout} class="select select-bordered select-sm"
on:change={onBoxViewLayoutChange} bind:value={boxViewLayout}
aria-label="Choose box view layout density" on:change={onBoxViewLayoutChange}
> aria-label="Choose box view layout density"
<option value="comfortable">Comfortable (2 boxes/row)</option> >
<option value="compact">Compact (3 boxes/row)</option> <option value="comfortable">Comfortable (2 boxes/row)</option>
<option value="ultra">Ultra (4 boxes/row)</option> <option value="compact">Compact (3 boxes/row)</option>
</select> <option value="ultra">Ultra (4 boxes/row)</option>
</select>
<div class="flex flex-wrap items-center gap-2 text-sm"> <div class="flex flex-wrap items-center gap-2 text-sm">
<span class="font-semibold">Legend:</span> <span class="font-semibold">Legend:</span>
<span <span
class="inline-flex items-center gap-1 rounded-full border border-green-700 bg-green-600 px-2 py-0.5 text-white" class="inline-flex items-center gap-1 rounded-full border border-green-700 bg-green-600 px-2 py-0.5 text-white"
> >
<span class="status-badge status-badge--caught" aria-hidden="true"> <span class="status-badge status-badge--caught" aria-hidden="true">
<svg <svg
class="status-icon" class="status-icon"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="#ffffff" stroke="#ffffff"
stroke-width="3" stroke-width="3"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
> >
<path d="M5 13l4 4L19 7" /> <path d="M5 13l4 4L19 7" />
</svg> </svg>
</span>
<span>Caught</span>
</span> </span>
<span>Caught</span> <span
</span> class="inline-flex items-center gap-1 rounded-full border border-yellow-700 bg-yellow-500 px-2 py-0.5 text-white"
<span >
class="inline-flex items-center gap-1 rounded-full border border-yellow-700 bg-yellow-500 px-2 py-0.5 text-white" <span class="status-badge status-badge--evolve" aria-hidden="true">
> <svg
<span class="status-badge status-badge--evolve" aria-hidden="true"> class="status-icon"
<svg viewBox="0 0 24 24"
class="status-icon" fill="none"
viewBox="0 0 24 24" stroke="#ffffff"
fill="none" stroke-width="3"
stroke="#ffffff" stroke-linecap="round"
stroke-width="3" stroke-linejoin="round"
stroke-linecap="round" >
stroke-linejoin="round" <path d="M12 19V5" />
> <path d="M5 12l7-7 7 7" />
<path d="M12 19V5" /> </svg>
<path d="M5 12l7-7 7 7" /> </span>
</svg> <span>Caught but needs to evolve</span>
</span> </span>
<span>Caught but needs to evolve</span> <span
</span> class="inline-flex items-center gap-1 rounded-full border border-sky-700 bg-sky-600 px-2 py-0.5 text-white"
<span >
class="inline-flex items-center gap-1 rounded-full border border-sky-700 bg-sky-600 px-2 py-0.5 text-white" <span class="status-badge status-badge--home" aria-hidden="true">
> <svg
<span class="status-badge status-badge--home" aria-hidden="true"> class="status-icon"
<svg viewBox="0 0 24 24"
class="status-icon" fill="#ffffff"
viewBox="0 0 24 24" stroke="#ffffff"
fill="#ffffff" stroke-width="2"
stroke="#ffffff" stroke-linecap="round"
stroke-width="2" stroke-linejoin="round"
stroke-linecap="round" >
stroke-linejoin="round" <path d="M12 3 3 10.5V21a1 1 0 0 0 1 1h5v-6h6v6h5a1 1 0 0 0 1-1V10.5L12 3Z" />
> </svg>
<path d="M12 3 3 10.5V21a1 1 0 0 0 1 1h5v-6h6v6h5a1 1 0 0 0 1-1V10.5L12 3Z" /> </span>
</svg> <span>In Home</span>
</span> </span>
<span>In Home</span> </div>
</span> </div>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-4">
<span class="font-semibold">Filters:</span>
<label class="label cursor-pointer gap-2 p-0">
<input
type="checkbox"
class="checkbox checkbox-sm"
bind:checked={filterNotCaught}
/>
<span class="label-text">Not caught</span>
</label>
<label class="label cursor-pointer gap-2 p-0">
<input
type="checkbox"
class="checkbox checkbox-sm"
bind:checked={filterNeedsToEvolve}
/>
<span class="label-text">Needs to evolve</span>
</label>
<label class="label cursor-pointer gap-2 p-0">
<input
type="checkbox"
class="checkbox checkbox-sm"
bind:checked={filterInHome}
on:change={onInHomeFilterChange}
/>
<span class="label-text">In HOME</span>
</label>
<label class="label cursor-pointer gap-2 p-0">
<input
type="checkbox"
class="checkbox checkbox-sm"
bind:checked={filterNotInHome}
on:change={onNotInHomeFilterChange}
/>
<span class="label-text">Not in HOME</span>
</label>
</div>
<div
class="flex flex-wrap items-center justify-between gap-2 rounded-box bg-base-200/60 px-3 py-2"
>
<div class="text-sm text-base-content/70">
<span class="font-semibold text-base-content">Caught</span>
{overallCaughtCount}
<span class="mx-2"></span>
<span class="font-semibold text-base-content">Not caught</span>
{overallNotCaughtCount}
<span class="mx-2"></span>
<span class="font-semibold text-base-content">Needs to evolve</span>
{overallNeedsToEvolveCount}
<span class="mx-2"></span>
<span class="font-semibold text-base-content">In HOME</span>
{overallInHomeCount}
<span class="mx-2"></span>
<span class="font-semibold text-base-content">Not in HOME</span>
{overallNotInHomeCount}
</div>
<div class="badge badge-outline">
Showing {filteredTotal} of {overallTotal}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -198,15 +355,20 @@
<div class="grid grid-cols-6"> <div class="grid grid-cols-6">
{#each combinedData as { pokedexEntry, catchRecord }, index} {#each combinedData as { pokedexEntry, catchRecord }, index}
{@const placement = calculateBoxPlacement(index)} {@const placement = calculateBoxPlacement(index)}
{@const isFilteredOut =
filtersActive && !!filtersKey && !matchesFilters(catchRecord)}
{#if placement.box === boxNumber} {#if placement.box === boxNumber}
<button <button
type="button" type="button"
class="pokemon-box {cellStatusClasses( class="pokemon-box {cellStatusClasses(catchRecord)} {isFilteredOut
catchRecord ? 'pokemon-box--filtered-out'
)} hover:scale-105 hover:shadow-lg hover:z-50 transition-all cursor-pointer relative" : 'hover:scale-105 hover:shadow-lg hover:z-50'} transition-all cursor-pointer relative"
style="grid-column-start: {placement.column}; grid-row-start: {placement.row}; style="grid-column-start: {placement.column}; grid-row-start: {placement.row};
{cellBackgroundColourStyle(index, catchRecord)}" {cellBackgroundColourStyle(index, catchRecord)}"
on:click={() => onPokemonClick({ pokedexEntry, catchRecord })} aria-disabled={isFilteredOut}
on:click={() => {
if (!isFilteredOut) onPokemonClick({ pokedexEntry, catchRecord });
}}
aria-label="View details for {pokedexEntry.pokemon}. Status: {statusLabel( aria-label="View details for {pokedexEntry.pokemon}. Status: {statusLabel(
catchRecord catchRecord
)}" )}"
@@ -350,6 +512,12 @@
border-radius: 0; border-radius: 0;
} }
.pokemon-box--filtered-out {
opacity: 0.25;
filter: grayscale(1);
cursor: default;
}
.pokemon-box-inner { .pokemon-box-inner {
padding: var(--cell-padding, 1rem); padding: var(--cell-padding, 1rem);
width: 100%; width: 100%;