Compare commits

..

No commits in common. "c1f6007b250caeea34ec8e9c3369258f26a8099b" and "83cb89aedd05c64eb2b623013fa095a1443f9896" have entirely different histories.

7 changed files with 118 additions and 206 deletions

View File

@ -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`. |
| **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`). |
| **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`). |
## Data & storage split

View File

@ -1,160 +1,98 @@
import { createStore } from './createStore.js';
import { settings } from './settings.js';
/**
* Per-game Pokémon tracking.
* The "unified Pokémon selection".
*
* 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).
* 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.
*/
export const selection = createStore('pdx.selection', {
version: 3,
version: 2,
pokemon: {},
games: {},
});
const GBLANK = { seen: false, caught: false };
const PBLANK = { favorite: false, note: '' };
const BLANK = { seen: false, caught: false, 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 } : {} };
export function entry(id) {
return selection.get().pokemon[id] || BLANK;
}
// 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()) {
export function toggle(id, field) {
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 };
const current = s.pokemon[id] || BLANK;
const next = {
...current,
[field]: !current[field],
updatedAt: new Date().toISOString(),
};
// Catching something implies you've seen it.
if (field === 'caught' && next.caught) next.seen = true;
return { ...s, games: { ...s.games, [vgKey]: { ...bucket, [id]: next } } };
return { ...s, pokemon: { ...s.pokemon, [id]: next } };
});
}
export function setNote(id, note) {
selection.set((s) => {
const cur = s.pokemon[id] || PBLANK;
const current = s.pokemon[id] || BLANK;
return {
...s,
pokemon: { ...s.pokemon, [id]: { ...cur, note, updatedAt: new Date().toISOString() } },
pokemon: {
...s.pokemon,
[id]: { ...current, 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 };
});
}
/**
* Merge seen/caught lists into one game's bucket (a save-file import).
* Never clears. `game` defaults to the selected one.
* 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.
*/
export function importFlags({ seen = [], caught = [], game } = {}) {
const vgKey = game || curGame();
export function replaceFlags({ seen = [], caught = [] } = {}) {
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 } };
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 };
});
}
/** 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;
/** Aggregate seen/caught counts over an arbitrary list of national ids. */
export function stats(speciesIds) {
const p = selection.get().pokemon;
let seen = 0;
let caught = 0;
for (const id of speciesIds) {
const e = get(id);
const e = p[id];
if (!e) continue;
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');
}

View File

@ -112,53 +112,45 @@
color: var(--text-dim);
}
/* Team toolbar: primary mode switch on the left, reference shortcuts on the
right. One band so the reference links read as part of the view's chrome
rather than a strip pasted above it. */
.teamview__bar {
/* Row of shortcut chips under a view header (Team → Natures / Type chart / …) */
.toollinks {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 16px;
margin-bottom: 16px;
gap: 8px;
margin: -6px 0 18px;
}
.teamview__bar .seg {
margin-bottom: 0;
}
.teamview__refs {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.reflink {
.toollink {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 10px;
border-radius: 8px;
color: var(--text-dim);
font-size: 0.8rem;
gap: 6px;
padding: 7px 13px 7px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text);
font-size: 0.82rem;
font-weight: 600;
text-decoration: none;
line-height: 1;
transition: color 0.12s ease, background 0.12s ease;
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.reflink:hover {
color: var(--text);
background: var(--surface-2);
.toollink:hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
}
.reflink:active {
.toollink:active {
transform: scale(0.96);
}
.reflink__ico {
.toollink__ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
font-size: 0.82rem;
}
.teamcompare__note {
margin: 0 0 10px;
font-size: 0.82rem;
color: var(--text-dim);
font-size: 0.72rem;
}
.link {

View File

@ -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, entry, gameEntry } from '../store/selection.js';
import { selection, stats } 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 gameEntry(sp.id).updatedAt || '';
case 'caught': return selection.get().pokemon[sp.id]?.updatedAt || '';
default: return 0;
}
}
@ -404,16 +404,17 @@ 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 = entry(species.id);
if (e.caught) caught++;
if (e.favorite) favorite++;
const e = p[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 };
}
@ -428,6 +429,7 @@ 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;
@ -448,7 +450,8 @@ 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;
return filterTest(entry(species.id), species);
const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false };
return filterTest(e, species);
});
const dir = sortDesc ? -1 : 1;

View File

@ -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, statsNational } from '../store/selection.js';
import { selection, stats } 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), vg.key);
const s = stats(speciesIdsFor(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 — caught in any game.
const nat = statsNational(snap.species.map((s) => s.id));
// National total — always shown.
const nat = stats(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', {}, 'What youve caught in each game. Tap one to switch to it.'),
el('p', {}, 'Your one unified caught record, counted against each games dex. Games you havent played are tucked away. Tap a game to switch to it.'),
),
el(
'p',

View File

@ -1,7 +1,6 @@
import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection, importFlags, replaceFlags, normalizeSelection } from '../store/selection.js';
import { prettify } from '../data/pokedex-resolver.js';
import { selection, importFlags, replaceFlags } from '../store/selection.js';
import { chooseDialog } from '../lib/dialog.js';
import { team } from '../store/team.js';
import { formTracking } from '../store/formTracking.js';
@ -145,30 +144,23 @@ 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 ${prettify(target)}` },
{ key: 'replace', label: `Replace ${prettify(target)} with this`, class: 'button--danger' },
{ key: 'merge', label: 'Add to my Pokédex' },
{ key: 'replace', label: 'Replace mine with this', class: 'button--danger' },
{ key: null, label: 'Cancel', class: 'button--ghost' },
],
});
if (choice === 'merge') {
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)}.`;
importFlags({ seen: dex.seen, caught: dex.caught });
markPlayed(...(dex.versionGroups || []));
saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
} else if (choice === 'replace') {
replaceFlags({ seen: dex.seen, caught: dex.caught, game: target });
markPlayed(...cands);
saveNote.textContent = `Set ${prettify(target)}'s Pokédex from ${who}.`;
replaceFlags({ seen: dex.seen, caught: dex.caught });
markPlayed(...(dex.versionGroups || []));
saveNote.textContent = `Replaced tracking with ${who}'s Pokédex (favourites and notes kept).`;
} else {
saveNote.textContent = '';
}
@ -186,7 +178,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(normalizeSelection(data.selection));
if (data.selection) selection.replace(data.selection);
if (data.team) team.replace(data.team);
if (data.formTracking) formTracking.replace(data.formTracking);
if (data.shinyHunts) shinyHunts.replace(data.shinyHunts);
@ -276,7 +268,7 @@ export async function SettingsView() {
type: 'button',
onclick: () => {
if (confirm('Clear all seen/caught/favorite tracking? This cannot be undone.')) {
selection.replace({ version: 3, pokemon: {}, games: {} });
selection.replace({ version: 2, pokemon: {} });
}
},
}, 'Clear tracking data'),

View File

@ -67,19 +67,16 @@ export async function TeamView() {
{ class: 'view__header' },
el('h1', {}, 'Team'),
el('p', {}, `Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats.`),
),
el(
'div',
{ class: 'teamview__bar' },
seg,
el(
'nav',
{ class: 'teamview__refs', 'aria-label': 'Reference tools' },
el('a', { class: 'reflink', href: '#/natures' }, el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '✦'), 'Natures'),
el('a', { class: 'reflink', href: '#/types' }, el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '▦'), 'Type chart'),
el('a', { class: 'reflink', href: '#/breeding' }, el('span', { class: 'reflink__ico', 'aria-hidden': 'true' }, '⬡'), 'Breeding'),
{ class: 'toollinks' },
el('a', { class: 'toollink', href: '#/natures' }, el('span', { class: 'toollink__ico', 'aria-hidden': 'true' }, '✦'), 'Natures'),
el('a', { class: 'toollink', href: '#/types' }, el('span', { class: 'toollink__ico', 'aria-hidden': 'true' }, '▦'), 'Type chart'),
el('a', { class: 'toollink', href: '#/compare' }, el('span', { class: 'toollink__ico', 'aria-hidden': 'true' }, '⇄'), 'Compare'),
el('a', { class: 'toollink', href: '#/breeding' }, el('span', { class: 'toollink__ico', 'aria-hidden': 'true' }, '⬡'), 'Breeding'),
),
),
seg,
lineup,
body,
);
@ -358,17 +355,7 @@ export async function TeamView() {
function compare(mem) {
const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
const gen = vg ? vg.generation : 9;
return el(
'div',
{ class: 'teamcompare' },
el(
'p',
{ class: 'teamcompare__note' },
'Your six side by side. ',
el('a', { class: 'link', href: '#/compare' }, 'Compare any Pokémon →'),
),
CompareTable(mem, { gen, style: settings.get().spriteStyle }),
);
return CompareTable(mem, { gen, style: settings.get().spriteStyle });
}
// ---- Damage calculator ------------------------------------------