Team: chip-style tool links; Progress: lead with games you've played

The Natures / Type chart / Compare / Breeding links under the Team header
were plain dot-separated text. Make them a row of pill chips with small
accent icon badges (.toollinks / .toollink).

Progress by game showed a caught total for all 32 version groups the
moment anything was marked, since the tracking model is one unified
caught record. Add a "played games" set (pdx.playedGames, vg keys),
auto-marked when a game is selected or a save is imported (savedex now
returns candidate versionGroups per generation). #/progress leads with a
"Your games" section and tucks the rest behind a "Show all other games"
toggle (persisted). Included in JSON export/import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-10 10:34:20 -04:00
parent c1ce972ce9
commit a70f3c9976
9 changed files with 184 additions and 29 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 for every game at once. 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

@ -87,6 +87,7 @@ function tryGen1(bytes) {
return {
gen: 1,
game: 'Red / Blue / Yellow',
versionGroups: ['red-blue', 'yellow'],
trainer: decodeName(bytes.subarray(G1.name, G1.name + 11), GB_CHARS, 0x50),
seen,
caught,
@ -100,8 +101,8 @@ function tryGen1(bytes) {
* ------------------------------------------------------------------ */
const G2 = {
gs: { name: 0x200b, owned: 0x2a4c, seen: 0x2a6c, ck: 0x2d69, ckFrom: 0x2009, ckTo: 0x2d68, label: 'Gold / Silver' },
cr: { name: 0x2009, owned: 0x2a27, seen: 0x2a47, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82, label: 'Crystal' },
gs: { name: 0x200b, owned: 0x2a4c, seen: 0x2a6c, ck: 0x2d69, ckFrom: 0x2009, ckTo: 0x2d68, label: 'Gold / Silver', vgs: ['gold-silver'] },
cr: { name: 0x2009, owned: 0x2a27, seen: 0x2a47, ck: 0x2d0d, ckFrom: 0x2009, ckTo: 0x2b82, label: 'Crystal', vgs: ['crystal'] },
};
const G2_SPECIES = 251;
@ -118,6 +119,7 @@ function tryGen2Variant(bytes, v) {
return {
gen: 2,
game: v.label,
versionGroups: v.vgs,
trainer: decodeName(bytes.subarray(v.name, v.name + 11), GB_CHARS, 0x50),
seen,
caught,
@ -190,6 +192,7 @@ function tryGen3(bytes) {
return {
gen: 3,
game: magic === 0xb9 ? 'FireRed / LeafGreen' : 'Ruby / Sapphire / Emerald',
versionGroups: magic === 0xb9 ? ['firered-leafgreen'] : ['ruby-sapphire', 'emerald'],
trainer: decodeName(bytes.subarray(s0, s0 + 7), GBA_CHARS, 0xff),
seen,
caught,
@ -279,6 +282,7 @@ function tryGen4(raw) {
return {
gen: 4,
game: 'Diamond / Pearl / Platinum · HeartGold / SoulSilver',
versionGroups: ['diamond-pearl', 'platinum', 'heartgold-soulsilver'],
trainer: '',
seen: best.seen,
caught: best.caught,
@ -294,7 +298,7 @@ function readGen5Slot(bytes, slotBase, v) {
const caught = bitsToList(bytes, p + GEN5.caught, GEN5.species);
const seen = orSeen(bytes, p, GEN5.seen, GEN5.size, GEN5.species);
if (!seen.length || caught.some((n) => !seen.includes(n))) return null;
return { seen, caught, label: v.label };
return { seen, caught, label: v.label, vg: v.dex === GEN5.bw.dex ? 'black-white' : 'black-2-white-2' };
}
function tryGen5(raw) {
@ -312,6 +316,7 @@ function tryGen5(raw) {
return {
gen: 5,
game: best.label,
versionGroups: [best.vg],
trainer: '',
seen: best.seen,
caught: best.caught,
@ -326,7 +331,7 @@ function tryGen5(raw) {
/**
* @param {ArrayBuffer} buffer
* @returns {{ gen:number, game:string, trainer:string, seen:number[], caught:number[], nationalUnlocked:boolean }}
* @returns {{ gen:number, game:string, versionGroups:string[], trainer:string, seen:number[], caught:number[], nationalUnlocked:boolean }}
* @throws {Error} with a user-facing message if it can't be read.
*/
export function parseSaveDex(buffer) {

View File

@ -7,6 +7,7 @@ import { initRouter, rerender } from './router.js';
import { Nav } from './components/Nav.js';
import { settings, applyTheme } from './store/settings.js';
import { ui } from './store/ui.js';
import { markPlayed } from './store/playedGames.js';
import { onInstallChange, promptInstall } from './lib/install.js';
applyTheme();
@ -30,10 +31,13 @@ if (!location.hash) {
// game changes so its data (flavour text, learnset, matchups, evolution,
// abilities, locations, era sprites) matches the new game.
let lastVersionGroup = settings.get().versionGroup;
// Selecting a game counts as having played it (see store/playedGames.js).
markPlayed(lastVersionGroup);
settings.subscribe((s) => {
applyTheme();
if (s.versionGroup !== lastVersionGroup) {
lastVersionGroup = s.versionGroup;
markPlayed(s.versionGroup);
if ((location.hash || '').startsWith('#/pokemon/')) rerender();
}
});

27
src/store/playedGames.js Normal file
View File

@ -0,0 +1,27 @@
import { createStore } from './createStore.js';
/**
* The set of games (PokéAPI version-group keys) the user has actually
* played imported a save for, or opened the dex of at least once.
*
* The tracking model is one unified caught record per Pokémon, so every
* game technically shows a caught total the moment you mark anything. That
* over-reports games you've never touched. `#/progress` uses this set to
* lead with the games you've played and tuck the rest away.
*/
export const playedGames = createStore('pdx.playedGames', { keys: [] });
export function isPlayed(key) {
return playedGames.get().keys.includes(key);
}
export function markPlayed(...keys) {
const have = new Set(playedGames.get().keys);
const add = keys.filter((k) => k && k !== 'all' && !have.has(k));
if (!add.length) return;
playedGames.set((s) => ({ ...s, keys: [...s.keys, ...add] }));
}
export function unmarkPlayed(key) {
playedGames.set((s) => ({ ...s, keys: s.keys.filter((k) => k !== key) }));
}

View File

@ -34,4 +34,5 @@ export const ui = createStore('pdx.ui', {
itCat: '',
itSort: 'name',
abSort: 'name',
progShowAll: false,
});

View File

@ -112,6 +112,47 @@
color: var(--text-dim);
}
/* Row of shortcut chips under a view header (Team → Natures / Type chart / …) */
.toollinks {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: -6px 0 18px;
}
.toollink {
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;
font-weight: 600;
text-decoration: none;
line-height: 1;
transition: transform 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.toollink:hover {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
}
.toollink: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);
color: var(--accent);
font-size: 0.72rem;
}
.link {
color: var(--accent);
text-decoration: none;
@ -3104,6 +3145,32 @@
cursor: default;
font-weight: 700;
}
.prog__row--played {
box-shadow: inset 3px 0 0 var(--accent);
}
.prog__toggle {
align-self: flex-start;
margin-top: 8px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text-dim);
font: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
}
.prog__toggle:hover {
border-color: var(--accent);
color: var(--text);
}
.prog__rest {
display: flex;
flex-direction: column;
gap: 6px;
opacity: 0.75;
}
.prog__name {
font-size: 0.82rem;
font-weight: 600;

View File

@ -2,6 +2,8 @@ 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 { ui } from '../store/ui.js';
import { playedGames, isPlayed } from '../store/playedGames.js';
import { dexesForVersionGroup, versionGroupsByGeneration } from '../data/pokedex-resolver.js';
function bar(seen, caught, total) {
@ -28,11 +30,33 @@ export async function ProgressView() {
return [...ids];
};
function gameRow(vg, { played }) {
const s = stats(speciesIdsFor(vg.key));
return el(
'button',
{
type: 'button',
class: `prog__row${played ? ' prog__row--played' : ''}`,
onclick: () => {
settings.set({ versionGroup: vg.key, pokedex: null });
location.hash = '#/';
},
},
el('span', { class: 'prog__name' }, vg.name),
bar(s.seen, s.caught, s.total),
el('span', { class: 'prog__count' }, `${s.caught} / ${s.total}`),
);
}
const list = el('div', { class: 'prog' });
function render() {
clear(list);
// National total
const gens = versionGroupsByGeneration(snap);
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));
list.append(
el(
@ -44,27 +68,48 @@ export async function ProgressView() {
),
);
for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) {
list.append(el('h3', { class: 'prog__gen' }, `Gen ${generation.id} · ${generation.name}`));
for (const vg of versionGroups) {
const s = stats(speciesIdsFor(vg.key));
// Games you've played (imported a save for, or opened at least once).
if (anyPlayed) {
list.append(el('h3', { class: 'prog__gen' }, 'Your games'));
for (const { versionGroups } of gens) {
for (const vg of versionGroups) {
if (isPlayed(vg.key)) list.append(gameRow(vg, { played: true }));
}
}
}
// Everything else — behind a toggle, since a single unified caught
// record makes every game report a total whether or not you've touched it.
const rest = [];
let restCount = 0;
for (const { generation, versionGroups } of gens) {
const unplayed = versionGroups.filter((vg) => !isPlayed(vg.key));
if (!unplayed.length) continue;
rest.push(el('h3', { class: 'prog__gen' }, `Gen ${generation.id} · ${generation.name}`));
for (const vg of unplayed) {
rest.push(gameRow(vg, { played: false }));
restCount++;
}
}
if (restCount) {
if (anyPlayed) {
list.append(
el(
'button',
{
type: 'button',
class: 'prog__row',
class: 'prog__toggle',
onclick: () => {
settings.set({ versionGroup: vg.key, pokedex: null });
location.hash = '#/';
ui.set({ progShowAll: !ui.get().progShowAll });
render();
},
},
el('span', { class: 'prog__name' }, vg.name),
bar(s.seen, s.caught, s.total),
el('span', { class: 'prog__count' }, `${s.caught} / ${s.total}`),
showAll ? 'Hide other games' : `Show all other games (${restCount})`,
),
);
}
if (showAll) list.append(el('div', { class: 'prog__rest' }, ...rest));
}
}
render();
@ -75,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. Tap a game 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',
@ -88,7 +133,11 @@ export async function ProgressView() {
list,
);
const off = selection.subscribe(render);
onTeardown(view, () => off());
const offSel = selection.subscribe(render);
const offPlayed = playedGames.subscribe(render);
onTeardown(view, () => {
offSel();
offPlayed();
});
return view;
}

View File

@ -5,6 +5,7 @@ import { chooseDialog } from '../lib/dialog.js';
import { team } from '../store/team.js';
import { formTracking } from '../store/formTracking.js';
import { shinyHunts } from '../store/shinyHunts.js';
import { playedGames, markPlayed } from '../store/playedGames.js';
import { parseSaveDex } from '../lib/savedex.js';
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
@ -154,9 +155,11 @@ export async function SettingsView() {
});
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}.`;
} 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).`;
} else {
saveNote.textContent = '';
@ -179,6 +182,7 @@ export async function SettingsView() {
if (data.team) team.replace(data.team);
if (data.formTracking) formTracking.replace(data.formTracking);
if (data.shinyHunts) shinyHunts.replace(data.shinyHunts);
if (data.playedGames) playedGames.replace(data.playedGames);
alert('Import complete.');
} catch {
alert('That file could not be read as a Pokédex backup.');
@ -314,6 +318,7 @@ function exportData() {
team: team.get(),
formTracking: formTracking.get(),
shinyHunts: shinyHunts.get(),
playedGames: playedGames.get(),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: 'application/json',

View File

@ -66,17 +66,14 @@ export async function TeamView() {
'header',
{ class: 'view__header' },
el('h1', {}, 'Team'),
el('p', {}, `Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats.`),
el(
'p',
{},
`Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `,
el('a', { class: 'link', href: '#/natures' }, 'Natures'),
' · ',
el('a', { class: 'link', href: '#/types' }, 'Type chart'),
' · ',
el('a', { class: 'link', href: '#/compare' }, 'Compare'),
' · ',
el('a', { class: 'link', href: '#/breeding' }, 'Breeding'),
'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'),
),
),
seg,