- selection: clearAllGames() wipes seen/caught for every game but keeps the global favorites/notes record. - Progress view: a "Reset caught / seen…" action (custom dialog) that calls clearAllGames — the soft, favorites-preserving reset. Settings → "Clear tracking data" stays as the full wipe (now spells out that it also drops favorites and notes, and points here for the softer one). - Swept the remaining window.confirm / window.alert calls (Settings clear-tracking, clear-cache, backup import result; ShinyView delete-hunt) onto chooseDialog for a consistent look. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
178 lines
6.1 KiB
JavaScript
178 lines
6.1 KiB
JavaScript
import { createStore } from './createStore.js';
|
|
import { settings } from './settings.js';
|
|
|
|
/**
|
|
* Per-game Pokémon tracking.
|
|
*
|
|
* 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: 3,
|
|
pokemon: {},
|
|
games: {},
|
|
});
|
|
|
|
const GBLANK = { seen: false, caught: false };
|
|
const PBLANK = { favorite: false, note: '' };
|
|
|
|
/** 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 } : {} };
|
|
}
|
|
|
|
// 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 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, games: { ...s.games, [vgKey]: { ...bucket, [id]: next } } };
|
|
});
|
|
}
|
|
|
|
export function setNote(id, note) {
|
|
selection.set((s) => {
|
|
const cur = s.pokemon[id] || PBLANK;
|
|
return {
|
|
...s,
|
|
pokemon: { ...s.pokemon, [id]: { ...cur, note, updatedAt: new Date().toISOString() } },
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Merge seen/caught lists into one game's bucket (a save-file import).
|
|
* Never clears. `game` defaults to the selected one.
|
|
*/
|
|
export function importFlags({ seen = [], caught = [], game } = {}) {
|
|
const vgKey = game || curGame();
|
|
selection.set((s) => {
|
|
const stamp = new Date().toISOString();
|
|
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 } };
|
|
});
|
|
}
|
|
|
|
/** 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 } };
|
|
});
|
|
}
|
|
|
|
/** Drop one game's seen/caught bucket entirely (its own progress reset). */
|
|
export function clearGame(vgKey) {
|
|
selection.set((s) => {
|
|
if (!s.games[vgKey]) return s;
|
|
const games = { ...s.games };
|
|
delete games[vgKey];
|
|
return { ...s, games };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wipe seen/caught for every game (a "National Dex reset"). Favorites and
|
|
* notes — the global `pokemon` record — are kept. Use `selection.replace`
|
|
* for the full wipe that also drops those.
|
|
*/
|
|
export function clearAllGames() {
|
|
selection.set((s) => ({ ...s, games: {} }));
|
|
}
|
|
|
|
/** 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 = 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');
|
|
}
|