diff --git a/README.md b/README.md index 50a6fc5..d029d3a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/store/selection.js b/src/store/selection.js index a484e13..3e8f200 100644 --- a/src/store/selection.js +++ b/src/store/selection.js @@ -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'); +} diff --git a/src/views/DexGrid.js b/src/views/DexGrid.js index 55796b8..6ab992d 100644 --- a/src/views/DexGrid.js +++ b/src/views/DexGrid.js @@ -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; diff --git a/src/views/ProgressView.js b/src/views/ProgressView.js index ed73c9d..e7ed5ed 100644 --- a/src/views/ProgressView.js +++ b/src/views/ProgressView.js @@ -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', diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index cf403e1..6e85efa 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -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'),