add catchemall progress bars and hide uncatchable pokemon by default
All checks were successful
Test and deploy staging / staging (push) Successful in 28s
All checks were successful
Test and deploy staging / staging (push) Successful in 28s
This commit is contained in:
58
src/lib/catch-em-all/progress.test.ts
Normal file
58
src/lib/catch-em-all/progress.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
53
src/lib/catch-em-all/progress.ts
Normal file
53
src/lib/catch-em-all/progress.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user