add catchemall progress bars and hide uncatchable pokemon by default
All checks were successful
Test and deploy staging / staging (push) Successful in 28s

This commit is contained in:
2026-09-23 04:59:22 +00:00
parent eca43c82d0
commit f1cd0ef849
3 changed files with 193 additions and 19 deletions

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { availabilityValues } from '$lib/availability';
import { summarizeProgress } from './progress';
const catchable = availabilityValues.indexOf('catchable');
const transferOnly = availabilityValues.indexOf('transfer_only');
const pokemon = [
{
id: 1,
entries: [
[11, 1, catchable],
[12, 2, catchable]
]
},
{
id: 2,
entries: [
[21, 1, catchable],
[22, 2, transferOnly]
]
},
{ id: 3, entries: [[31, 2, transferOnly]] }
] as const;
describe('Catchemall progress', () => {
it('counts unique Pokémon and completed games only from selected catchable entries', () => {
expect(summarizeProgress(pokemon, new Set([1, 2, 3]), new Set([11, 12, 31]))).toEqual({
catchableEntries: 3,
caughtEntries: 2,
catchablePokemon: 2,
seenPokemon: 1,
completedPokemon: 1,
catchableGames: 2,
completedGames: 1
});
});
it('recomputes all denominators when the selected games change', () => {
expect(summarizeProgress(pokemon, new Set([2]), new Set([11, 12, 21]))).toEqual({
catchableEntries: 1,
caughtEntries: 1,
catchablePokemon: 1,
seenPokemon: 1,
completedPokemon: 1,
catchableGames: 1,
completedGames: 1
});
expect(summarizeProgress(pokemon, new Set([3]), new Set([11, 12, 21]))).toEqual({
catchableEntries: 0,
caughtEntries: 0,
catchablePokemon: 0,
seenPokemon: 0,
completedPokemon: 0,
catchableGames: 0,
completedGames: 0
});
});
});

View File

@@ -0,0 +1,53 @@
import { availabilityValues } from '$lib/availability';
type PokemonWithEntries = {
id: number;
entries: readonly (readonly [id: number, gameId: number, availability: number, note?: string])[];
};
export function summarizeProgress(
pokemon: readonly PokemonWithEntries[],
selectedGameIds: ReadonlySet<number>,
caughtIds: ReadonlySet<number>
) {
let catchableEntries = 0;
let caughtEntries = 0;
let catchablePokemon = 0;
let seenPokemon = 0;
let completedPokemon = 0;
const games = new Map<number, { total: number; caught: number }>();
for (const species of pokemon) {
let speciesEntries = 0;
let speciesCaught = 0;
for (const [entryId, gameId, availability] of species.entries) {
if (!selectedGameIds.has(gameId) || availabilityValues[availability] !== 'catchable')
continue;
speciesEntries++;
const game = games.get(gameId) ?? { total: 0, caught: 0 };
game.total++;
if (caughtIds.has(entryId)) {
speciesCaught++;
game.caught++;
}
games.set(gameId, game);
}
if (speciesEntries === 0) continue;
catchableEntries += speciesEntries;
caughtEntries += speciesCaught;
catchablePokemon++;
if (speciesCaught > 0) seenPokemon++;
if (speciesCaught === speciesEntries) completedPokemon++;
}
return {
catchableEntries,
caughtEntries,
catchablePokemon,
seenPokemon,
completedPokemon,
catchableGames: games.size,
completedGames: [...games.values()].filter((game) => game.caught === game.total).length
};
}

View File

@@ -6,6 +6,7 @@
import type { PageProps } from './$types';
import type { Game } from '$lib/server/db/schema';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { summarizeProgress } from '$lib/catch-em-all/progress';
type Entry = { id: number; availability: Availability; note: string | null };
let { data, form }: PageProps = $props();
@@ -59,7 +60,6 @@
const visibleGames = $derived(
data.gamesConfigured ? data.games.filter((game) => selectedGameIds.has(game.id)) : data.games
);
const visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
let viewGameId = $state<number | null>(null);
$effect(() => {
if (!visibleGames.some((game) => game.id === viewGameId))
@@ -67,26 +67,48 @@
});
const viewGame = $derived(visibleGames.find((game) => game.id === viewGameId));
const catchableEntries = $derived(
data.pokemon
.flatMap((pokemon) => pokemon.entries)
.filter(
([, gameId, code]) => availabilityValues[code] === 'catchable' && visibleGameIds.has(gameId)
)
);
const total = $derived(catchableEntries.length);
const caughtCount = $derived(
catchableEntries.reduce((n, [id]) => n + (caught.has(id) ? 1 : 0), 0)
);
const percentage = $derived(total > 0 ? ((caughtCount / total) * 100).toFixed(1) : '0.0');
const visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
const progress = $derived(summarizeProgress(data.pokemon, visibleGameIds, caught));
const progressBars = $derived([
{
label: 'seen at least once',
value: progress.seenPokemon,
total: progress.catchablePokemon,
unit: 'Pokémon'
},
{
label: 'seen in every catchable game',
value: progress.completedPokemon,
total: progress.catchablePokemon,
unit: 'Pokémon'
},
{
label: 'games completed',
value: progress.completedGames,
total: progress.catchableGames,
unit: 'games'
}
]);
function percent(value: number, total: number) {
return total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
}
let search = $state('');
let showNonCatchable = $state(false);
let pageNumber = $state(1);
const pageSize = 25;
const filteredPokemon = $derived(
data.pokemon.filter((pokemon) =>
`${pokemon.number} ${pokemon.name}`.toLowerCase().includes(search.trim().toLowerCase())
data.pokemon.filter((pokemon) => {
if (
!showNonCatchable &&
viewGameId !== null &&
entryMap.get(pokemon.id)?.get(viewGameId)?.availability !== 'catchable'
)
return false;
return `${pokemon.number} ${pokemon.name}`
.toLowerCase()
.includes(search.trim().toLowerCase());
})
);
const pageCount = $derived(Math.max(1, Math.ceil(filteredPokemon.length / pageSize)));
$effect(() => {
@@ -151,12 +173,40 @@
<div class="mt-8 border-y border-[var(--site-border)] py-6">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">overall progress</p>
<p class="text-2xl font-semibold tabular-nums">{percentage}%</p>
<p class="text-2xl font-semibold tabular-nums">
{percent(progress.caughtEntries, progress.catchableEntries)}%
</p>
</div>
<div class="mt-3">
<ProgressBar value={caughtCount} max={total} label="Overall catch progress" />
<ProgressBar
value={progress.caughtEntries}
max={progress.catchableEntries}
label="Overall catch progress"
/>
</div>
<p class="mt-2 text-base text-[var(--site-muted)]">{caughtCount} of {total} entries caught</p>
<p class="mt-2 text-base text-[var(--site-muted)]">
{progress.caughtEntries} of {progress.catchableEntries} catchable entries caught
</p>
<div class="mt-8 grid gap-x-10 gap-y-7 lg:grid-cols-3">
{#each progressBars as item (item.label)}
<div class="min-w-0">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<p class="font-medium">{item.label}</p>
<p class="text-xl font-semibold tabular-nums">{percent(item.value, item.total)}%</p>
</div>
<div class="mt-3">
<ProgressBar value={item.value} max={item.total} label={item.label} small />
</div>
<p class="mt-2 text-base text-[var(--site-muted)]">
{item.value} of {item.total}
{item.unit}
</p>
</div>
{/each}
</div>
<p class="mt-6 text-base text-[var(--site-muted)]">
counts use your selected games and confirmed catchable entries.
</p>
</div>
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
@@ -174,6 +224,7 @@
>game
<select
bind:value={viewGameId}
onchange={() => (pageNumber = 1)}
disabled={!visibleGames.length}
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
>
@@ -181,6 +232,18 @@
</select>
</label>
</div>
<label class="mt-5 flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={showNonCatchable}
onchange={(event) => {
showNonCatchable = event.currentTarget.checked;
pageNumber = 1;
}}
class="h-5 w-5 accent-[var(--site-text)]"
/>
show non-catchable Pokémon
</label>
{#if viewGame}
<div class="mt-8">