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 { PageProps } from './$types';
|
||||||
import type { Game } from '$lib/server/db/schema';
|
import type { Game } from '$lib/server/db/schema';
|
||||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||||
|
import { summarizeProgress } from '$lib/catch-em-all/progress';
|
||||||
|
|
||||||
type Entry = { id: number; availability: Availability; note: string | null };
|
type Entry = { id: number; availability: Availability; note: string | null };
|
||||||
let { data, form }: PageProps = $props();
|
let { data, form }: PageProps = $props();
|
||||||
@@ -59,7 +60,6 @@
|
|||||||
const visibleGames = $derived(
|
const visibleGames = $derived(
|
||||||
data.gamesConfigured ? data.games.filter((game) => selectedGameIds.has(game.id)) : data.games
|
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);
|
let viewGameId = $state<number | null>(null);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!visibleGames.some((game) => game.id === viewGameId))
|
if (!visibleGames.some((game) => game.id === viewGameId))
|
||||||
@@ -67,26 +67,48 @@
|
|||||||
});
|
});
|
||||||
const viewGame = $derived(visibleGames.find((game) => game.id === viewGameId));
|
const viewGame = $derived(visibleGames.find((game) => game.id === viewGameId));
|
||||||
|
|
||||||
const catchableEntries = $derived(
|
const visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
|
||||||
data.pokemon
|
const progress = $derived(summarizeProgress(data.pokemon, visibleGameIds, caught));
|
||||||
.flatMap((pokemon) => pokemon.entries)
|
const progressBars = $derived([
|
||||||
.filter(
|
{
|
||||||
([, gameId, code]) => availabilityValues[code] === 'catchable' && visibleGameIds.has(gameId)
|
label: 'seen at least once',
|
||||||
)
|
value: progress.seenPokemon,
|
||||||
);
|
total: progress.catchablePokemon,
|
||||||
const total = $derived(catchableEntries.length);
|
unit: 'Pokémon'
|
||||||
const caughtCount = $derived(
|
},
|
||||||
catchableEntries.reduce((n, [id]) => n + (caught.has(id) ? 1 : 0), 0)
|
{
|
||||||
);
|
label: 'seen in every catchable game',
|
||||||
const percentage = $derived(total > 0 ? ((caughtCount / total) * 100).toFixed(1) : '0.0');
|
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 search = $state('');
|
||||||
|
let showNonCatchable = $state(false);
|
||||||
let pageNumber = $state(1);
|
let pageNumber = $state(1);
|
||||||
const pageSize = 25;
|
const pageSize = 25;
|
||||||
const filteredPokemon = $derived(
|
const filteredPokemon = $derived(
|
||||||
data.pokemon.filter((pokemon) =>
|
data.pokemon.filter((pokemon) => {
|
||||||
`${pokemon.number} ${pokemon.name}`.toLowerCase().includes(search.trim().toLowerCase())
|
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)));
|
const pageCount = $derived(Math.max(1, Math.ceil(filteredPokemon.length / pageSize)));
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -151,12 +173,40 @@
|
|||||||
<div class="mt-8 border-y border-[var(--site-border)] py-6">
|
<div class="mt-8 border-y border-[var(--site-border)] py-6">
|
||||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
<p class="font-medium">overall progress</p>
|
<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>
|
||||||
<div class="mt-3">
|
<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>
|
</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>
|
||||||
|
|
||||||
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||||
@@ -174,6 +224,7 @@
|
|||||||
>game
|
>game
|
||||||
<select
|
<select
|
||||||
bind:value={viewGameId}
|
bind:value={viewGameId}
|
||||||
|
onchange={() => (pageNumber = 1)}
|
||||||
disabled={!visibleGames.length}
|
disabled={!visibleGames.length}
|
||||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
|
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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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}
|
{#if viewGame}
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user