Compare commits

..

2 Commits

Author SHA1 Message Date
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
31a00fdf52 Team: fold reference links into the toolbar, drop the duplicate Compare
The reference links sat in their own strip under the header, reading as an
afterthought, and "Compare" appeared both there and as a Team mode.

- Reference links (Natures / Type chart / Breeding) now share one bar with
  the Coverage/Compare/Calc switch — quiet text links on the right, so
  they're still one tap away but clearly secondary chrome, not a pasted-on
  row. Right-aligned on wide screens, wrapping under the switch on mobile.
- Removed the standalone "Compare" reference link. The Team's own Compare
  mode now carries a "Compare any Pokémon →" link to #/compare in its
  intro line, so the free-form compare tool is reachable in context and
  only one "Compare" label is ever visible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 10:44:48 -04:00
7 changed files with 206 additions and 118 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`. |
| **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

View File

@ -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');
}

View File

@ -112,45 +112,53 @@
color: var(--text-dim);
}
/* Row of shortcut chips under a view header (Team → Natures / Type chart / …) */
.toollinks {
/* 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 {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: -6px 0 18px;
align-items: center;
justify-content: space-between;
gap: 8px 16px;
margin-bottom: 16px;
}
.toollink {
.teamview__bar .seg {
margin-bottom: 0;
}
.teamview__refs {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.reflink {
display: inline-flex;
align-items: center;
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;
gap: 5px;
padding: 6px 10px;
border-radius: 8px;
color: var(--text-dim);
font-size: 0.8rem;
font-weight: 600;
text-decoration: none;
line-height: 1;
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
transition: color 0.12s ease, background 0.12s ease;
}
.toollink:hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
.reflink:hover {
color: var(--text);
background: var(--surface-2);
}
.toollink:active {
.reflink:active {
transform: scale(0.96);
}
.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);
.reflink__ico {
color: var(--accent);
font-size: 0.72rem;
font-size: 0.82rem;
}
.teamcompare__note {
margin: 0 0 10px;
font-size: 0.82rem;
color: var(--text-dim);
}
.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 } 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;

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 } 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 games dex. Games you havent played are tucked away. Tap a game to switch to it.'),
el('p', {}, 'What youve caught in each game. Tap one to switch to it.'),
),
el(
'p',

View File

@ -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'),

View File

@ -67,16 +67,19 @@ 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: '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'),
{ 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'),
),
),
seg,
lineup,
body,
);
@ -355,7 +358,17 @@ export async function TeamView() {
function compare(mem) {
const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
const gen = vg ? vg.generation : 9;
return CompareTable(mem, { gen, style: settings.get().spriteStyle });
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 }),
);
}
// ---- Damage calculator ------------------------------------------