dex/src/views/ProgressView.js
chris c1f6007b25 Track seen/caught per game, not one unified record
Catching a Pokémon in Blue was counting it as caught in Gold, Emerald and
every other game, because the whole app ran on one caught record keyed by
National Dex id. Progress by game just intersected that set with each
game's species list, so the per-game bars were meaningless.

selection store is now:
  pokemon: { [id]: { favorite, note } }          -- global (describe the mon)
  games:   { [vg]: { [id]: { seen, caught } } }   -- per game

- entry(id) / toggle(id, field) / stats(ids) default to the selected game;
  favorite/note stay global. 'all' (All-games mode) reads as the union of
  every game -- "caught anywhere" -- and is a real bucket you write to when
  no specific game is selected.
- v2 stores migrate on load (normalizeSelection): favorite/note lift out,
  seen/caught drop into the 'all' bucket so nothing is falsely attributed
  to a game. Same path runs on JSON backup import.
- ProgressView: each game row counts its own bucket; National row =
  statsNational (union). Intro text slimmed.
- Save import writes into one game bucket -- the selected game if it's a
  candidate for that save, else the parser's first guess -- and the dialog
  names which game it's importing to.
- DexGrid feed (ring, filter counts, caught-sort), PokemonDetail track
  buttons, search dots, shiny "found it" all follow the selected game.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 10:54:04 -04:00

144 lines
4.5 KiB
JavaScript

import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { selection, stats, statsNational } from '../store/selection.js';
import { ui } from '../store/ui.js';
import { playedGames, isPlayed } from '../store/playedGames.js';
import { dexesForVersionGroup, versionGroupsByGeneration } from '../data/pokedex-resolver.js';
function bar(seen, caught, total) {
const pc = total ? (caught / total) * 100 : 0;
const ps = total ? (seen / total) * 100 : 0;
const track = el('div', { class: 'prog__track' });
const seenEl = el('div', { class: 'prog__seen' });
const caughtEl = el('div', { class: 'prog__caught' });
seenEl.style.width = `${ps}%`;
caughtEl.style.width = `${pc}%`;
track.append(seenEl, caughtEl);
return track;
}
export async function ProgressView() {
const view = el('section', { class: 'view lookup progressview' });
const snap = await loadSnapshot();
const speciesIdsFor = (vgKey) => {
const ids = new Set();
for (const dex of dexesForVersionGroup(snap, vgKey)) {
for (const [sid] of dex.entries) ids.add(sid);
}
return [...ids];
};
function gameRow(vg, { played }) {
const s = stats(speciesIdsFor(vg.key), vg.key);
return el(
'button',
{
type: 'button',
class: `prog__row${played ? ' prog__row--played' : ''}`,
onclick: () => {
settings.set({ versionGroup: vg.key, pokedex: null });
location.hash = '#/';
},
},
el('span', { class: 'prog__name' }, vg.name),
bar(s.seen, s.caught, s.total),
el('span', { class: 'prog__count' }, `${s.caught} / ${s.total}`),
);
}
const list = el('div', { class: 'prog' });
function render() {
clear(list);
const gens = versionGroupsByGeneration(snap);
const anyPlayed = gens.some(({ versionGroups }) => versionGroups.some((vg) => isPlayed(vg.key)));
const showAll = ui.get().progShowAll || !anyPlayed;
// National total — caught in any game.
const nat = statsNational(snap.species.map((s) => s.id));
list.append(
el(
'div',
{ class: 'prog__row prog__row--nat' },
el('span', { class: 'prog__name' }, 'National Dex'),
bar(nat.seen, nat.caught, nat.total),
el('span', { class: 'prog__count' }, `${nat.caught} / ${nat.total}`),
),
);
// Games you've played (imported a save for, or opened at least once).
if (anyPlayed) {
list.append(el('h3', { class: 'prog__gen' }, 'Your games'));
for (const { versionGroups } of gens) {
for (const vg of versionGroups) {
if (isPlayed(vg.key)) list.append(gameRow(vg, { played: true }));
}
}
}
// Everything else — behind a toggle, since a single unified caught
// record makes every game report a total whether or not you've touched it.
const rest = [];
let restCount = 0;
for (const { generation, versionGroups } of gens) {
const unplayed = versionGroups.filter((vg) => !isPlayed(vg.key));
if (!unplayed.length) continue;
rest.push(el('h3', { class: 'prog__gen' }, `Gen ${generation.id} · ${generation.name}`));
for (const vg of unplayed) {
rest.push(gameRow(vg, { played: false }));
restCount++;
}
}
if (restCount) {
if (anyPlayed) {
list.append(
el(
'button',
{
type: 'button',
class: 'prog__toggle',
onclick: () => {
ui.set({ progShowAll: !ui.get().progShowAll });
render();
},
},
showAll ? 'Hide other games' : `Show all other games (${restCount})`,
),
);
}
if (showAll) list.append(el('div', { class: 'prog__rest' }, ...rest));
}
}
render();
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/settings' }, '‹ Settings')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Progress by game'),
el('p', {}, 'What you’ve caught in each game. Tap one to switch to it.'),
),
el(
'p',
{ class: 'prog__legend' },
el('span', { class: 'prog__key prog__key--caught' }),
' caught ',
el('span', { class: 'prog__key prog__key--seen' }),
' seen',
),
list,
);
const offSel = selection.subscribe(render);
const offPlayed = playedGames.subscribe(render);
onTeardown(view, () => {
offSel();
offPlayed();
});
return view;
}