From a70f3c9976ae0fe716fd0d38d8f818f3f2ff468d Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 10:34:20 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- README.md | 2 +- src/lib/savedex.js | 13 +++++-- src/main.js | 4 ++ src/store/playedGames.js | 27 ++++++++++++++ src/store/ui.js | 1 + src/styles/layout.css | 67 ++++++++++++++++++++++++++++++++++ src/views/ProgressView.js | 77 ++++++++++++++++++++++++++++++++------- src/views/SettingsView.js | 5 +++ src/views/TeamView.js | 17 ++++----- 9 files changed, 184 insertions(+), 29 deletions(-) create mode 100644 src/store/playedGames.js diff --git a/README.md b/README.md index bd55fc7..50a6fc5 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 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 diff --git a/src/lib/savedex.js b/src/lib/savedex.js index afb6543..0cd74bc 100644 --- a/src/lib/savedex.js +++ b/src/lib/savedex.js @@ -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) { diff --git a/src/main.js b/src/main.js index dde87fd..373ef14 100644 --- a/src/main.js +++ b/src/main.js @@ -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(); } }); diff --git a/src/store/playedGames.js b/src/store/playedGames.js new file mode 100644 index 0000000..d9d0cca --- /dev/null +++ b/src/store/playedGames.js @@ -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) })); +} diff --git a/src/store/ui.js b/src/store/ui.js index 01c1513..8b6ccc9 100644 --- a/src/store/ui.js +++ b/src/store/ui.js @@ -34,4 +34,5 @@ export const ui = createStore('pdx.ui', { itCat: '', itSort: 'name', abSort: 'name', + progShowAll: false, }); diff --git a/src/styles/layout.css b/src/styles/layout.css index ff8c556..dbbbef5 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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; diff --git a/src/views/ProgressView.js b/src/views/ProgressView.js index d0157de..ed73c9d 100644 --- a/src/views/ProgressView.js +++ b/src/views/ProgressView.js @@ -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 game’s dex. Tap a game to switch to it.'), + 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', @@ -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; } diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index ee72f63..cf403e1 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -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', diff --git a/src/views/TeamView.js b/src/views/TeamView.js index 0fd4203..38e351f 100644 --- a/src/views/TeamView.js +++ b/src/views/TeamView.js @@ -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,