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
This commit is contained in:
parent
31a00fdf52
commit
c1f6007b25
@ -9,7 +9,7 @@ framework.
|
||||
| Feature | How it works |
|
||||
| --- | --- |
|
||||
| **Game series selection** | Pick a *game* (PokéAPI version group, e.g. Scarlet & Violet). If it has more than one regional dex (Paldea / Kitakami / Blueberry) a sub-dex switcher appears. The choice drives which species show, their regional numbering, and which version's flavor text the detail page uses. Stored in `localStorage`. |
|
||||
| **Unified Pokémon selection** | One tracking record per Pokémon, keyed by **National Dex id**. Mark Pikachu once and every game's view reflects it — each dex computes its own "Seen / Caught" totals by intersecting its species list with that single map. `#/progress` breaks that down per game, leading with the ones you've actually played (selected, or imported a save for) and tucking the rest behind a toggle. The active Pokémon is also a single deep-linkable route (`#/pokemon/25`). |
|
||||
| **Per-game Pokémon tracking** | Seen / caught are stored **per game** (version-group key) — catching Pikachu in Blue says nothing about your Gold save. `favorite` and `note` are global (they describe the Pokémon, keyed by National Dex id). The `all` bucket ("All games" mode) reads as the union — "caught anywhere" — and is where a pre-per-game save or an older backup migrates to. `#/progress` shows each game's own tally (National = the union), leading with the games you've played and tucking the rest behind a toggle. The active Pokémon is also a single deep-linkable route (`#/pokemon/25`). |
|
||||
|
||||
## Data & storage split
|
||||
|
||||
|
||||
@ -1,98 +1,160 @@
|
||||
import { createStore } from './createStore.js';
|
||||
import { settings } from './settings.js';
|
||||
|
||||
/**
|
||||
* The "unified Pokémon selection".
|
||||
* Per-game Pokémon tracking.
|
||||
*
|
||||
* One record per Pokémon, keyed by NATIONAL dex id. That key is what makes
|
||||
* it unified: mark Pikachu once and every game's view — Kanto, Paldea,
|
||||
* National, search results — reflects it, because each view computes its
|
||||
* own progress by intersecting its species list with this single map.
|
||||
* Seen / caught are stored *per game* (PokéAPI version-group key) — catching
|
||||
* Pikachu in Blue says nothing about your Gold save. `favorite` and `note`
|
||||
* are the exception: those describe the Pokémon itself, so they're global,
|
||||
* one record keyed by National dex id.
|
||||
*
|
||||
* pokemon: { [id]: { favorite, note, updatedAt } } // global
|
||||
* games: { [vgKey]: { [id]: { seen, caught, updatedAt } } } // per game
|
||||
*
|
||||
* The 'all' key ("All games" / National mode) is special: reading it returns
|
||||
* the union of every game — "have I caught this anywhere". It's also a real
|
||||
* bucket you write to when you mark something while no specific game is
|
||||
* selected (and where a pre-per-game save's data was migrated to).
|
||||
*/
|
||||
export const selection = createStore('pdx.selection', {
|
||||
version: 2,
|
||||
version: 3,
|
||||
pokemon: {},
|
||||
games: {},
|
||||
});
|
||||
|
||||
const BLANK = { seen: false, caught: false, favorite: false, note: '' };
|
||||
const GBLANK = { seen: false, caught: false };
|
||||
const PBLANK = { favorite: false, note: '' };
|
||||
|
||||
export function entry(id) {
|
||||
return selection.get().pokemon[id] || BLANK;
|
||||
/** Convert any older (flat, one-record) shape to the current per-game one. */
|
||||
export function normalizeSelection(raw) {
|
||||
if (!raw || typeof raw !== 'object') return { version: 3, pokemon: {}, games: {} };
|
||||
if (raw.version >= 3 && raw.games) {
|
||||
return { version: 3, pokemon: raw.pokemon || {}, games: raw.games || {} };
|
||||
}
|
||||
const pokemon = {};
|
||||
const all = {};
|
||||
for (const [id, e] of Object.entries(raw.pokemon || {})) {
|
||||
if (e.favorite || (e.note && String(e.note).trim())) {
|
||||
pokemon[id] = { favorite: !!e.favorite, note: e.note || '', updatedAt: e.updatedAt };
|
||||
}
|
||||
if (e.seen || e.caught) {
|
||||
all[id] = { seen: !!e.seen, caught: !!e.caught, updatedAt: e.updatedAt };
|
||||
}
|
||||
}
|
||||
return { version: 3, pokemon, games: Object.keys(all).length ? { all } : {} };
|
||||
}
|
||||
|
||||
export function toggle(id, field) {
|
||||
// One-time migration of an existing v2 store on this device.
|
||||
{
|
||||
const cur = selection.get();
|
||||
if (!cur || cur.version < 3 || !cur.games) selection.replace(normalizeSelection(cur));
|
||||
}
|
||||
|
||||
function curGame() {
|
||||
return settings.get().versionGroup || 'all';
|
||||
}
|
||||
|
||||
function unionEntry(id) {
|
||||
const games = selection.get().games;
|
||||
let seen = false;
|
||||
let caught = false;
|
||||
let updatedAt = '';
|
||||
for (const k in games) {
|
||||
const e = games[k][id];
|
||||
if (!e) continue;
|
||||
if (e.seen || e.caught) seen = true;
|
||||
if (e.caught) caught = true;
|
||||
if (e.updatedAt && e.updatedAt > updatedAt) updatedAt = e.updatedAt;
|
||||
}
|
||||
return { seen, caught, updatedAt };
|
||||
}
|
||||
|
||||
/** Raw seen/caught for one game (union when vgKey === 'all'). */
|
||||
export function gameEntry(id, vgKey = curGame()) {
|
||||
if (vgKey === 'all') return unionEntry(id);
|
||||
return selection.get().games[vgKey]?.[id] || GBLANK;
|
||||
}
|
||||
|
||||
/** Merged view for a game: its seen/caught + the global favorite/note. */
|
||||
export function entry(id, vgKey = curGame()) {
|
||||
const g = gameEntry(id, vgKey);
|
||||
const p = selection.get().pokemon[id] || PBLANK;
|
||||
return { seen: !!g.seen, caught: !!g.caught, favorite: !!p.favorite, note: p.note || '' };
|
||||
}
|
||||
|
||||
export function toggle(id, field, vgKey = curGame()) {
|
||||
selection.set((s) => {
|
||||
const current = s.pokemon[id] || BLANK;
|
||||
const next = {
|
||||
...current,
|
||||
[field]: !current[field],
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
// Catching something implies you've seen it.
|
||||
const stamp = new Date().toISOString();
|
||||
if (field === 'favorite') {
|
||||
const cur = s.pokemon[id] || PBLANK;
|
||||
return {
|
||||
...s,
|
||||
pokemon: { ...s.pokemon, [id]: { ...cur, favorite: !cur.favorite, updatedAt: stamp } },
|
||||
};
|
||||
}
|
||||
const bucket = s.games[vgKey] || {};
|
||||
const cur = bucket[id] || GBLANK;
|
||||
const next = { ...cur, [field]: !cur[field], updatedAt: stamp };
|
||||
if (field === 'caught' && next.caught) next.seen = true;
|
||||
return { ...s, pokemon: { ...s.pokemon, [id]: next } };
|
||||
return { ...s, games: { ...s.games, [vgKey]: { ...bucket, [id]: next } } };
|
||||
});
|
||||
}
|
||||
|
||||
export function setNote(id, note) {
|
||||
selection.set((s) => {
|
||||
const current = s.pokemon[id] || BLANK;
|
||||
const cur = s.pokemon[id] || PBLANK;
|
||||
return {
|
||||
...s,
|
||||
pokemon: {
|
||||
...s.pokemon,
|
||||
[id]: { ...current, note, updatedAt: new Date().toISOString() },
|
||||
},
|
||||
pokemon: { ...s.pokemon, [id]: { ...cur, note, updatedAt: new Date().toISOString() } },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Merge in seen/caught species lists (e.g. from a save-file import). Never clears. */
|
||||
export function importFlags({ seen = [], caught = [] } = {}) {
|
||||
selection.set((s) => {
|
||||
const pokemon = { ...s.pokemon };
|
||||
const stamp = new Date().toISOString();
|
||||
const bump = (id, patch) => {
|
||||
pokemon[id] = { ...(pokemon[id] || BLANK), ...patch, updatedAt: stamp };
|
||||
};
|
||||
for (const id of seen) bump(id, { seen: true });
|
||||
for (const id of caught) bump(id, { seen: true, caught: true });
|
||||
return { ...s, pokemon };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set seen/caught to exactly the given lists — favourites and notes are
|
||||
* kept, everything else's seen/caught is cleared. For "replace with this
|
||||
* save" imports.
|
||||
* Merge seen/caught lists into one game's bucket (a save-file import).
|
||||
* Never clears. `game` defaults to the selected one.
|
||||
*/
|
||||
export function replaceFlags({ seen = [], caught = [] } = {}) {
|
||||
export function importFlags({ seen = [], caught = [], game } = {}) {
|
||||
const vgKey = game || curGame();
|
||||
selection.set((s) => {
|
||||
const stamp = new Date().toISOString();
|
||||
const pokemon = {};
|
||||
for (const [id, e] of Object.entries(s.pokemon)) {
|
||||
if (e.favorite || (e.note && e.note.trim())) {
|
||||
pokemon[id] = { seen: false, caught: false, favorite: !!e.favorite, note: e.note || '', updatedAt: e.updatedAt || stamp };
|
||||
}
|
||||
}
|
||||
const bump = (id, patch) => {
|
||||
pokemon[id] = { ...(pokemon[id] || BLANK), ...patch, updatedAt: stamp };
|
||||
};
|
||||
for (const id of seen) bump(id, { seen: true });
|
||||
for (const id of caught) bump(id, { seen: true, caught: true });
|
||||
return { ...s, pokemon };
|
||||
const bucket = { ...(s.games[vgKey] || {}) };
|
||||
for (const id of seen) bucket[id] = { ...(bucket[id] || GBLANK), seen: true, updatedAt: stamp };
|
||||
for (const id of caught) bucket[id] = { ...(bucket[id] || GBLANK), seen: true, caught: true, updatedAt: stamp };
|
||||
return { ...s, games: { ...s.games, [vgKey]: bucket } };
|
||||
});
|
||||
}
|
||||
|
||||
/** Aggregate seen/caught counts over an arbitrary list of national ids. */
|
||||
export function stats(speciesIds) {
|
||||
const p = selection.get().pokemon;
|
||||
/** Set one game's bucket to exactly these lists (a "replace with this save" import). */
|
||||
export function replaceFlags({ seen = [], caught = [], game } = {}) {
|
||||
const vgKey = game || curGame();
|
||||
selection.set((s) => {
|
||||
const stamp = new Date().toISOString();
|
||||
const bucket = {};
|
||||
for (const id of seen) bucket[id] = { seen: true, caught: false, updatedAt: stamp };
|
||||
for (const id of caught) bucket[id] = { seen: true, caught: true, updatedAt: stamp };
|
||||
return { ...s, games: { ...s.games, [vgKey]: bucket } };
|
||||
});
|
||||
}
|
||||
|
||||
/** Aggregate seen/caught over a species list for one game (union when 'all'). */
|
||||
export function stats(speciesIds, vgKey = curGame()) {
|
||||
const get =
|
||||
vgKey === 'all'
|
||||
? unionEntry
|
||||
: (id) => selection.get().games[vgKey]?.[id] || GBLANK;
|
||||
let seen = 0;
|
||||
let caught = 0;
|
||||
for (const id of speciesIds) {
|
||||
const e = p[id];
|
||||
if (!e) continue;
|
||||
const e = get(id);
|
||||
if (e.seen || e.caught) seen++;
|
||||
if (e.caught) caught++;
|
||||
}
|
||||
return { seen, caught, total: speciesIds.length };
|
||||
}
|
||||
|
||||
/** Caught/seen across every game — the true National Dex tally. */
|
||||
export function statsNational(speciesIds) {
|
||||
return stats(speciesIds, 'all');
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { loadSnapshot } from '../data/snapshot.js';
|
||||
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
|
||||
import { settings } from '../store/settings.js';
|
||||
import { ui } from '../store/ui.js';
|
||||
import { selection, stats } from '../store/selection.js';
|
||||
import { selection, stats, entry, gameEntry } from '../store/selection.js';
|
||||
import { Card } from '../components/Card.js';
|
||||
import { ProgressRing } from '../components/ProgressRing.js';
|
||||
import { Sprite } from '../components/Sprite.js';
|
||||
@ -56,7 +56,7 @@ function sortValue(row, key) {
|
||||
case 'weight': return sp.weight ?? 0;
|
||||
case 'baseExp': return sp.baseExp ?? 0;
|
||||
case 'catchrate': return sp.captureRate ?? 0;
|
||||
case 'caught': return selection.get().pokemon[sp.id]?.updatedAt || '';
|
||||
case 'caught': return gameEntry(sp.id).updatedAt || '';
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
@ -404,17 +404,16 @@ export async function DexGrid() {
|
||||
);
|
||||
|
||||
function counts() {
|
||||
const p = selection.get().pokemon;
|
||||
let caught = 0;
|
||||
let favorite = 0;
|
||||
let legendary = 0;
|
||||
let notes = 0;
|
||||
for (const { species } of rows) {
|
||||
const e = p[species.id];
|
||||
if (e?.caught) caught++;
|
||||
if (e?.favorite) favorite++;
|
||||
const e = entry(species.id);
|
||||
if (e.caught) caught++;
|
||||
if (e.favorite) favorite++;
|
||||
if (species.isLegendary || species.isMythical) legendary++;
|
||||
if (e?.note && e.note.trim()) notes++;
|
||||
if (e.note && e.note.trim()) notes++;
|
||||
}
|
||||
return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary, notes };
|
||||
}
|
||||
@ -429,7 +428,6 @@ export async function DexGrid() {
|
||||
|
||||
function paintGrid() {
|
||||
const st = settings.get();
|
||||
const pokemonState = selection.get().pokemon;
|
||||
const gen = snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9;
|
||||
const boxed = st.spriteStyle === 'game' && gen <= 2;
|
||||
const filterTest = FILTERS.find((f) => f.key === filterKey).test;
|
||||
@ -450,8 +448,7 @@ export async function DexGrid() {
|
||||
if (filterEggGroup && !(species.eggGroups || []).includes(filterEggGroup)) return false;
|
||||
if (filterMinBst && (species.bst || 0) < filterMinBst) return false;
|
||||
if (filterFullyEvolved && !isFullyEvolved(species)) return false;
|
||||
const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false };
|
||||
return filterTest(e, species);
|
||||
return filterTest(entry(species.id), species);
|
||||
});
|
||||
|
||||
const dir = sortDesc ? -1 : 1;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { el, clear, onTeardown } from '../lib/dom.js';
|
||||
import { loadSnapshot } from '../data/snapshot.js';
|
||||
import { settings } from '../store/settings.js';
|
||||
import { selection, stats } from '../store/selection.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';
|
||||
@ -31,7 +31,7 @@ export async function ProgressView() {
|
||||
};
|
||||
|
||||
function gameRow(vg, { played }) {
|
||||
const s = stats(speciesIdsFor(vg.key));
|
||||
const s = stats(speciesIdsFor(vg.key), vg.key);
|
||||
return el(
|
||||
'button',
|
||||
{
|
||||
@ -56,8 +56,8 @@ export async function ProgressView() {
|
||||
const anyPlayed = gens.some(({ versionGroups }) => versionGroups.some((vg) => isPlayed(vg.key)));
|
||||
const showAll = ui.get().progShowAll || !anyPlayed;
|
||||
|
||||
// National total — always shown.
|
||||
const nat = stats(snap.species.map((s) => s.id));
|
||||
// National total — caught in any game.
|
||||
const nat = statsNational(snap.species.map((s) => s.id));
|
||||
list.append(
|
||||
el(
|
||||
'div',
|
||||
@ -120,7 +120,7 @@ export async function ProgressView() {
|
||||
'header',
|
||||
{ class: 'view__header' },
|
||||
el('h1', {}, 'Progress by game'),
|
||||
el('p', {}, 'Your one unified caught record, counted against each game’s dex. Games you haven’t played are tucked away. Tap a game to switch to it.'),
|
||||
el('p', {}, 'What you’ve caught in each game. Tap one to switch to it.'),
|
||||
),
|
||||
el(
|
||||
'p',
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { el, onTeardown } from '../lib/dom.js';
|
||||
import { settings, applyTheme } from '../store/settings.js';
|
||||
import { selection, importFlags, replaceFlags } from '../store/selection.js';
|
||||
import { selection, importFlags, replaceFlags, normalizeSelection } from '../store/selection.js';
|
||||
import { prettify } from '../data/pokedex-resolver.js';
|
||||
import { chooseDialog } from '../lib/dialog.js';
|
||||
import { team } from '../store/team.js';
|
||||
import { formTracking } from '../store/formTracking.js';
|
||||
@ -144,23 +145,30 @@ export async function SettingsView() {
|
||||
return;
|
||||
}
|
||||
const who = dex.trainer || dex.game;
|
||||
// Which game bucket this save's catches belong to. The parser can't
|
||||
// always tell (an RSE save could be Ruby or Emerald) — prefer the
|
||||
// game the user currently has selected if it's one of the candidates.
|
||||
const cands = dex.versionGroups || [];
|
||||
const target = cands.includes(settings.get().versionGroup)
|
||||
? settings.get().versionGroup
|
||||
: cands[0];
|
||||
const choice = await chooseDialog({
|
||||
title: `${dex.game}${dex.trainer ? ` · ${dex.trainer}` : ''}`,
|
||||
body: `${dex.seen.length} seen · ${dex.caught.length} caught in this save.`,
|
||||
choices: [
|
||||
{ key: 'merge', label: 'Add to my Pokédex' },
|
||||
{ key: 'replace', label: 'Replace mine with this', class: 'button--danger' },
|
||||
{ key: 'merge', label: `Add to ${prettify(target)}` },
|
||||
{ key: 'replace', label: `Replace ${prettify(target)} with this`, class: 'button--danger' },
|
||||
{ key: null, label: 'Cancel', class: 'button--ghost' },
|
||||
],
|
||||
});
|
||||
if (choice === 'merge') {
|
||||
importFlags({ seen: dex.seen, caught: dex.caught });
|
||||
markPlayed(...(dex.versionGroups || []));
|
||||
saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
|
||||
importFlags({ seen: dex.seen, caught: dex.caught, game: target });
|
||||
markPlayed(...cands);
|
||||
saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen to ${prettify(target)}.`;
|
||||
} else if (choice === 'replace') {
|
||||
replaceFlags({ seen: dex.seen, caught: dex.caught });
|
||||
markPlayed(...(dex.versionGroups || []));
|
||||
saveNote.textContent = `Replaced tracking with ${who}'s Pokédex (favourites and notes kept).`;
|
||||
replaceFlags({ seen: dex.seen, caught: dex.caught, game: target });
|
||||
markPlayed(...cands);
|
||||
saveNote.textContent = `Set ${prettify(target)}'s Pokédex from ${who}.`;
|
||||
} else {
|
||||
saveNote.textContent = '';
|
||||
}
|
||||
@ -178,7 +186,7 @@ export async function SettingsView() {
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
if (data.settings) settings.replace(data.settings);
|
||||
if (data.selection) selection.replace(data.selection);
|
||||
if (data.selection) selection.replace(normalizeSelection(data.selection));
|
||||
if (data.team) team.replace(data.team);
|
||||
if (data.formTracking) formTracking.replace(data.formTracking);
|
||||
if (data.shinyHunts) shinyHunts.replace(data.shinyHunts);
|
||||
@ -268,7 +276,7 @@ export async function SettingsView() {
|
||||
type: 'button',
|
||||
onclick: () => {
|
||||
if (confirm('Clear all seen/caught/favorite tracking? This cannot be undone.')) {
|
||||
selection.replace({ version: 2, pokemon: {} });
|
||||
selection.replace({ version: 3, pokemon: {}, games: {} });
|
||||
}
|
||||
},
|
||||
}, 'Clear tracking data'),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user