diff --git a/src/lib/catch-em-all/progress.test.ts b/src/lib/catch-em-all/progress.test.ts new file mode 100644 index 0000000..2076389 --- /dev/null +++ b/src/lib/catch-em-all/progress.test.ts @@ -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 + }); + }); +}); diff --git a/src/lib/catch-em-all/progress.ts b/src/lib/catch-em-all/progress.ts new file mode 100644 index 0000000..4cd05d8 --- /dev/null +++ b/src/lib/catch-em-all/progress.ts @@ -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, + caughtIds: ReadonlySet +) { + let catchableEntries = 0; + let caughtEntries = 0; + let catchablePokemon = 0; + let seenPokemon = 0; + let completedPokemon = 0; + const games = new Map(); + + 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 + }; +} diff --git a/src/routes/projects/catch-em-all/+page.svelte b/src/routes/projects/catch-em-all/+page.svelte index a128475..db4ed5a 100644 --- a/src/routes/projects/catch-em-all/+page.svelte +++ b/src/routes/projects/catch-em-all/+page.svelte @@ -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(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 @@

overall progress

-

{percentage}%

+

+ {percent(progress.caughtEntries, progress.catchableEntries)}% +

- +
-

{caughtCount} of {total} entries caught

+

+ {progress.caughtEntries} of {progress.catchableEntries} catchable entries caught +

+
+ {#each progressBars as item (item.label)} +
+
+

{item.label}

+

{percent(item.value, item.total)}%

+
+
+ +
+

+ {item.value} of {item.total} + {item.unit} +

+
+ {/each} +
+

+ counts use your selected games and confirmed catchable entries. +

@@ -174,6 +224,7 @@ >game
+ {#if viewGame}