Compare commits

..

6 Commits

Author SHA1 Message Date
53f4f4381d Per-game caught breakdown (#/progress)
New view (linked from Settings > Your data): the unified caught record
counted against every game's dex at once — National total on top, then
each version group grouped by generation with a seen/caught bar and
X / Y count. Tapping a game switches the active version group and jumps
to the dex.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-09 18:52:01 -04:00
5f6414528e Compare any two Pokémon — standalone #/compare view
Extracted the Team Compare table into components/CompareTable.js and
reused it for a new #/compare route: its own picker (up to 4 Pokémon,
persisted in ui.compareIds), gen-aware types, per-stat max highlight.
Linked from the Team header alongside Natures / Type chart. TeamView's
Compare tab now just calls CompareTable too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-09 18:48:27 -04:00
490ad9b7f1 PWA polish: PNG/maskable icons, install prompt, manifest shortcuts
- Rasterised the SVG to pwa-192 / pwa-512 / pwa-maskable-512 (logo in the
  safe zone, full-bleed background) and a real apple-touch-icon.png;
  manifest now lists PNG "any" + a dedicated "maskable".
- Manifest shortcuts: Team, Search, Type chart.
- lib/install.js captures beforeinstallprompt so the app can offer
  installation itself — a one-time toast, and an "Install app" button in
  Settings (falls back to a "use your browser menu" hint where the event
  never fires, e.g. iOS).
- index.html: apple-touch-icon PNG + apple-mobile-web-app meta.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-09 18:18:19 -04:00
cfa30c987f Add a standalone interactive type chart page
New #/types route (linked from the Team header alongside Natures): the
full 18x18 attack/defend grid, gen-aware from the selected game — drops
Fairy pre-Gen 6, Steel/Dark pre-Gen 2, and applies the Gen 1 / pre-Gen 6
quirks the type-chart module already models. Tap a type on either edge to
highlight its row+column and get an offensive + defensive breakdown as
type chips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-09 18:12:31 -04:00
262e1a454e Align ability page with move/item; link Team at-a-glance names
- AbilityDetail: "Pokémon with this ability" grid now gets a filter box
  and the same game/generation narrowing as the move and item pages, plus
  a note when the ability postdates the selected game (Libero in
  Ruby/Sapphire -> "introduced in Gen 8").
- TeamView "At a glance": Fastest / Bulkiest names now link to their
  Pokémon pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-09 17:50:54 -04:00
9810eb0996 Item detail: list holders; link held items on the Pokémon page
Two more spots where data was shown as a dead number/label:

- ItemDetail "Held by N Pokémon" -> a clickable sprite grid, same as the
  move page. held_by_pokemon carries a per-version breakdown, so a
  specific game filters exactly (Leftovers in SV lists only what holds it
  there, not the all-games union).
- PokemonDetail "Held items" in the Training facts were plain text; now
  each links to its item detail page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-09 17:44:42 -04:00
21 changed files with 946 additions and 99 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. 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 for every game at once. The active Pokémon is also a single deep-linkable route (`#/pokemon/25`). |
## Data & storage split
@ -102,33 +102,40 @@ Working app with a type-themed UI:
/ "TMs only" / "HMs only" for the selected game and sort by power /
accuracy / recency — or, with a machine filter on, by TM/HM number
(numbers are baked into the snapshot). Items filter by category. Each
has its own detail page — the move page lists every Pokémon that can
learn it (filterable, narrowed to species that existed by the selected
game's generation). Query, scroll, tab and filters persist.
has its own detail page; the move, ability and item pages list the
Pokémon connected to them (learners / users / wild holders) as a
filterable grid, narrowed to the selected game (by generation, or
exactly by version for held items). Query, scroll, tab and filters
persist.
- **Team** — a lineup of up to 6. Coverage leads with a "weak spots"
summary (types that hit 2+ members, or that someone's weak to and nobody
resists), then a full matchup grid with a diverging resist◀│▶weak bar per
type; plus offensive STAB gaps and at-a-glance stats. A Compare table
stacks the six side by side. A third Calc mode is a damage calculator —
stacks the six side by side (the same table is also a standalone
`#/compare` view with its own picker — up to 4 Pokémon, persisted).
A third Calc mode is a damage calculator —
pick (or quick-pick from your team) an attacker + move + defender, and
get a damage range, % of HP, hits-to-KO, STAB/effectiveness badges, with
level/nature/EV controls; it's a base-stats estimate (31 IVs assumed,
no items/abilities/weather/terrain). All three follow the selected
game — its generation's type chart, era-accurate typings (pre-Gen-6
Clefairy is Normal, etc.), and era move data. Toggleable in the nav
from Settings.
from Settings. Links out to a Natures table and an interactive Type
chart (tap a type for its offensive + defensive breakdown; both
gen-aware).
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
colour, text size, an in-app reduce-motion override (on top of the OS
preference, which is always respected too), sprite style, haptic
feedback on catch (Vibration API), which screen to land on at launch,
JSON export/import.
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
cache-first LRU for sprites, in-app update toast.
cache-first LRU for sprites, in-app update toast. Installable: PNG +
maskable icons, `beforeinstallprompt` captured for an in-app "Install"
offer (toast + Settings button), manifest shortcuts to Team / Search /
Type chart.
Not yet done: version-exclusive badges, type/generation filters on the grid,
an interactive region map (PokéAPI has no map imagery or coordinates),
skeleton loaders, install-prompt handling, PNG/maskable raster icons
(currently SVG only), and a Lighthouse PWA pass.
Not yet done: version-exclusive badges, an interactive region map (PokéAPI
has no map imagery or coordinates), and a formal Lighthouse pass.
## Notes

View File

@ -12,7 +12,10 @@
content="A responsive, offline-capable Pokédex reference powered by PokéAPI."
/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Pokédex" />
<title>Pokédex</title>
</head>
<body>

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/pwa-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
public/pwa-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/pwa-maskable-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -0,0 +1,74 @@
import { el } from '../lib/dom.js';
import { Sprite } from './Sprite.js';
import { TypeChip } from './TypeChip.js';
import { typesForGen } from '../lib/type-resolve.js';
const STAT_ROWS = [
['hp', 'HP'],
['atk', 'Attack'],
['def', 'Defense'],
['spa', 'Sp. Atk'],
['spd', 'Sp. Def'],
['spe', 'Speed'],
];
/**
* Side-by-side stat / type comparison of 2+ snapshot species. Each stat
* row highlights the highest value; types are era-accurate for `gen`.
*/
export function CompareTable(members, { gen = 9, style } = {}) {
const mem = members;
const maxOf = (key) => Math.max(...mem.map((m) => m.stats?.[key] ?? 0));
const row = (label, cells, cls = '') =>
el('div', { class: `cmp__row ${cls}` }, el('span', { class: 'cmp__label' }, label), ...cells);
return el(
'div',
{ class: 'cmp' },
row(
'',
mem.map((sp) =>
el(
'a',
{ class: 'cmp__mem', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { style, size: 44, alt: sp.name }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
),
'cmp__row--head',
),
row(
'Type',
mem.map((sp) => el('span', { class: 'cmp__cell' }, ...typesForGen(sp, gen).map(TypeChip))),
),
...STAT_ROWS.map(([key, label]) => {
const mx = maxOf(key);
return row(
label,
mem.map((sp) => {
const v = sp.stats?.[key] ?? 0;
const bar = el('span', { class: 'cmp__bar' });
bar.style.width = `${(v / 200) * 100}%`;
return el(
'span',
{ class: `cmp__cell cmp__stat${v === mx ? ' is-max' : ''}` },
el('span', { class: 'cmp__val' }, String(v)),
el('span', { class: 'cmp__track' }, bar),
);
}),
);
}),
row(
'BST',
mem.map((sp) => {
const mx = Math.max(...mem.map((m) => m.bst || 0));
return el(
'span',
{ class: `cmp__cell cmp__stat${(sp.bst || 0) === mx ? ' is-max' : ''}` },
el('span', { class: 'cmp__val' }, String(sp.bst || 0)),
);
}),
'cmp__row--bst',
),
);
}

48
src/lib/install.js Normal file
View File

@ -0,0 +1,48 @@
/**
* Home-screen install: captures the browser's `beforeinstallprompt` so the
* app can offer installation on its own terms (a toast, a Settings button)
* instead of relying on the browser's mini-infobar.
*
* Import this early the event fires once, soon after load.
*/
let deferred = null;
const listeners = new Set();
const notify = () => {
for (const fn of listeners) fn(!!deferred);
};
if (typeof window !== 'undefined') {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferred = e;
notify();
});
window.addEventListener('appinstalled', () => {
deferred = null;
notify();
});
}
export function canInstall() {
return !!deferred;
}
export function onInstallChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
/** Show the native install dialog. Resolves true if the user accepted. */
export async function promptInstall() {
if (!deferred) return false;
deferred.prompt();
let outcome = 'dismissed';
try {
({ outcome } = await deferred.userChoice);
} catch {
/* ignore */
}
deferred = null;
notify();
return outcome === 'accepted';
}

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 { onInstallChange, promptInstall } from './lib/install.js';
applyTheme();
@ -58,6 +59,16 @@ const updateSW = registerSW({
},
});
// --- Install prompt: offer it once, unobtrusively ---------------------
let installOffered = false;
onInstallChange((can) => {
if (!can || installOffered) return;
installOffered = true;
showToast('Install this Pokédex for offline, full-screen use?', 'Install', () => {
promptInstall();
});
});
// --- Offline indicator -------------------------------------------------
const netPill = el('div', { class: 'netpill', role: 'status' }, 'Offline');
function syncNet() {

View File

@ -8,6 +8,9 @@ import { ItemDetail } from './views/ItemDetail.js';
import { AbilityDetail } from './views/AbilityDetail.js';
import { TeamView } from './views/TeamView.js';
import { NaturesView } from './views/NaturesView.js';
import { TypeChartView } from './views/TypeChartView.js';
import { CompareView } from './views/CompareView.js';
import { ProgressView } from './views/ProgressView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
import { prefersReducedMotion } from './store/settings.js';
@ -19,6 +22,9 @@ const routes = [
{ pattern: /^#\/ability\/(\d+)$/, view: (m) => AbilityDetail(Number(m[1])), skeleton: lookupSkeleton },
{ pattern: /^#\/team$/, view: () => TeamView() },
{ pattern: /^#\/natures$/, view: () => NaturesView() },
{ pattern: /^#\/types$/, view: () => TypeChartView() },
{ pattern: /^#\/compare$/, view: () => CompareView() },
{ pattern: /^#\/progress$/, view: () => ProgressView() },
{ pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() },
];

View File

@ -20,6 +20,7 @@ export const ui = createStore('pdx.ui', {
filterMinBst: 0,
filterFullyEvolved: false,
recent: [],
compareIds: [],
toolsOpen: false,
teamMode: 'coverage',
mvType: '',

View File

@ -1591,6 +1591,9 @@
.settings__actions {
flex-flow: row wrap;
}
.settings__install {
margin: 10px 0 0;
}
.field {
display: flex;
flex-direction: column;
@ -2909,3 +2912,190 @@
font-weight: 700;
color: color-mix(in srgb, var(--type-main) 55%, var(--text-dim));
}
/* ---- Type chart page ---------------------------------------- */
.tchart__scroll {
overflow-x: auto;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.tchart {
display: grid;
grid-template-columns: 2.6rem repeat(var(--n), 1.9rem);
gap: 2px;
min-width: max-content;
padding-bottom: 4px;
}
.tchart__corner {
font-size: 0.5rem;
line-height: 1.1;
white-space: pre;
color: var(--text-dim);
display: grid;
place-items: center;
text-align: center;
}
.tchart__col,
.tchart__row {
font-family: inherit;
font-size: 0.56rem;
font-weight: 800;
letter-spacing: 0.02em;
border: none;
cursor: pointer;
padding: 0;
min-height: 1.9rem;
border-radius: 4px;
color: var(--text);
background: color-mix(in srgb, var(--tc) 26%, var(--surface-2));
}
.tchart__col.is-sel,
.tchart__row.is-sel {
background: var(--tc);
color: #fff;
}
.tchart__cell {
display: grid;
place-items: center;
min-height: 1.9rem;
font-size: 0.66rem;
font-weight: 700;
border-radius: 4px;
background: var(--surface-2);
font-variant-numeric: tabular-nums;
}
.tchart__cell.m2 {
background: color-mix(in srgb, var(--danger) 42%, transparent);
color: #fff;
}
.tchart__cell.m05 {
background: color-mix(in srgb, var(--good) 32%, transparent);
}
.tchart__cell.m0 {
background: color-mix(in srgb, var(--text-dim) 35%, var(--surface-2));
color: var(--text-dim);
}
.tchart__cell.is-hot {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.tchart__legend {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
font-size: 0.75rem;
color: var(--text-dim);
margin: 12px 0;
}
.tchart__legend .tchart__cell {
width: 1.5rem;
min-height: 1.5rem;
flex: none;
}
.tchart__summary {
margin-top: 16px;
}
.tchart__srow {
display: flex;
gap: 8px;
align-items: baseline;
flex-wrap: wrap;
margin-bottom: 8px;
}
.tchart__slabel {
font-size: 0.76rem;
font-weight: 700;
color: var(--text-dim);
flex: none;
min-width: 9rem;
}
.compareview__body {
margin-top: 16px;
}
/* ---- Progress by game -------------------------------------- */
.prog {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
}
.prog__gen {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
margin: 12px 0 2px;
}
.prog__row {
display: grid;
grid-template-columns: 8.5rem 1fr 3.6rem;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 10px;
background: var(--surface-2);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.prog__row--nat {
background: color-mix(in srgb, var(--accent) 14%, var(--surface-2));
cursor: default;
font-weight: 700;
}
.prog__name {
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.prog__track {
position: relative;
height: 8px;
border-radius: 999px;
background: var(--surface);
overflow: hidden;
}
.prog__seen,
.prog__caught {
position: absolute;
inset: 0 auto 0 0;
border-radius: 999px;
}
.prog__seen {
background: color-mix(in srgb, var(--accent) 35%, transparent);
}
.prog__caught {
background: var(--good);
}
.prog__count {
font-size: 0.74rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-dim);
text-align: right;
}
.prog__legend {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--text-dim);
}
.prog__key {
display: inline-block;
width: 1.1rem;
height: 0.6rem;
border-radius: 999px;
vertical-align: middle;
}
.prog__key--caught { background: var(--good); }
.prog__key--seen { background: color-mix(in srgb, var(--accent) 35%, transparent); }

View File

@ -1,6 +1,7 @@
import { el, clear } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { prettify } from '../data/pokedex-resolver.js';
import { Sprite } from '../components/Sprite.js';
export async function AbilityDetail(id) {
@ -21,9 +22,51 @@ export async function AbilityDetail(id) {
}
const style = settings.get().spriteStyle;
const mons = a.pokemon.map((pid) => snap.speciesById.get(pid)).filter(Boolean);
const vg = settings.get().versionGroup;
const gen = snap.versionGroupByKey.get(vg)?.generation ?? 9;
// All-games: every species with the ability. A specific game: only those
// that existed by its generation.
let mons = a.pokemon.map((pid) => snap.speciesById.get(pid)).filter(Boolean);
if (vg !== 'all') mons = mons.filter((sp) => sp.generation <= gen);
mons.sort((x, y) => x.id - y.id);
const CAP = 400;
const grid = el('div', { class: 'ab-mons' });
function paint(q) {
const qq = (q || '').trim().toLowerCase().replace(/\s+/g, '-');
const shown = qq ? mons.filter((sp) => sp.name.includes(qq)) : mons;
clear(grid);
if (!shown.length) {
grid.append(el('p', { class: 'detail__muted' }, 'No matches.'));
return;
}
for (const sp of shown.slice(0, CAP)) {
grid.append(
el(
'a',
{ class: 'ab-mon', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { style, alt: sp.name, size: 48 }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
);
}
if (shown.length > CAP) {
grid.append(
el('p', { class: 'detail__muted' }, `+${shown.length - CAP} more — filter to narrow.`),
);
}
}
paint('');
const filterInput = el('input', {
type: 'search',
class: 'search__input learners__filter',
placeholder: 'Filter Pokémon…',
oninput: (e) => paint(e.target.value),
});
const predatesGame = vg !== 'all' && a.generation > gen;
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el(
@ -45,20 +88,21 @@ export async function AbilityDetail(id) {
el(
'section',
{ class: 'detail__section' },
el('h2', {}, `Pokémon with this ability (${mons.length})`),
mons.length
? el(
'div',
{ class: 'ab-mons' },
...mons.map((sp) =>
el(
'a',
{ class: 'ab-mon', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { style, alt: sp.name, size: 48 }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
'h2',
{},
`Pokémon with this ability${vg === 'all' ? '' : ` in ${prettify(vg)}`} (${mons.length})`,
),
predatesGame
? el(
'p',
{ class: 'detail__muted' },
`Not in ${prettify(vg)} — this ability was introduced in Gen ${a.generation}.`,
)
: null,
mons.length > 24 ? filterInput : null,
mons.length
? grid
: el('p', { class: 'detail__muted' }, 'None on record.'),
),
);

122
src/views/CompareView.js Normal file
View File

@ -0,0 +1,122 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { Sprite } from '../components/Sprite.js';
import { CompareTable } from '../components/CompareTable.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
const MAX = 4;
export async function CompareView() {
const view = el('section', { class: 'view lookup compareview' });
const snap = await loadSnapshot();
const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
const gen = vg ? vg.generation : 9;
let ids = (ui.get().compareIds || []).filter((id) => snap.speciesById.get(id)).slice(0, MAX);
const persist = () => ui.set({ compareIds: ids });
const slots = el('div', { class: 'lineup' });
const body = el('div', { class: 'compareview__body' });
function render() {
const style = settings.get().spriteStyle;
const mem = ids.map((id) => snap.speciesById.get(id)).filter(Boolean);
clear(slots);
for (const sp of mem) {
slots.append(
el(
'div',
{ class: 'lineup__slot' },
el(
'button',
{
class: 'lineup__x',
type: 'button',
'aria-label': `Remove ${sp.name}`,
onclick: () => {
ids = ids.filter((x) => x !== sp.id);
persist();
render();
},
},
'✕',
),
el(
'a',
{ href: `#/pokemon/${sp.id}`, class: 'lineup__link' },
Sprite(sp.id, { style, alt: sp.name, size: 64 }),
el('span', { class: 'lineup__name' }, sp.name.replace(/-/g, ' ')),
),
),
);
}
if (mem.length < MAX) {
slots.append(
el(
'button',
{
class: 'lineup__add',
type: 'button',
onclick: () =>
openPokemonPicker((id) => {
if (ids.includes(id) || ids.length >= MAX) return;
ids = [...ids, id];
persist();
render();
}),
},
'',
el('span', {}, 'Add'),
),
);
}
if (mem.length) {
slots.append(
el(
'button',
{
class: 'lineup__clear',
type: 'button',
onclick: () => {
ids = [];
persist();
render();
},
},
'Clear',
),
);
}
clear(body);
body.append(
mem.length >= 2
? CompareTable(mem, { gen, style })
: el('p', { class: 'detail__muted' }, 'Add two or more Pokémon to compare their types and base stats.'),
);
}
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/team' }, ' Team')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Compare'),
el(
'p',
{},
vg ? `Types shown for ${vg.name}.` : 'Types shown for the newest games.',
),
),
slots,
body,
);
render();
const offSettings = settings.subscribe(render);
onTeardown(view, () => offSettings());
return view;
}

View File

@ -1,9 +1,13 @@
import { el, clear } from '../lib/dom.js';
import { getItem } from '../data/api.js';
import { loadSnapshot } from '../data/snapshot.js';
import { prettify } from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js';
import { Sprite } from '../components/Sprite.js';
const ITEM_SPRITE = (name) =>
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`;
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const english = (entries, key) => {
const hit = (entries || []).find((e) => e.language.name === 'en');
@ -18,6 +22,8 @@ export async function ItemDetail(id) {
const view = el('section', { class: 'view lookup' });
view.append(el('div', { class: 'view--loading' }, 'Loading item…'));
const snap = await loadSnapshot();
let d;
try {
d = await getItem(id);
@ -42,6 +48,56 @@ export async function ItemDetail(id) {
const img = el('img', { class: 'lookup__item-img', alt: d.name, src: ITEM_SPRITE(d.name) });
img.addEventListener('error', () => img.remove(), { once: true });
// Pokémon that hold this item in the wild. held_by_pokemon carries a
// per-version breakdown, so for a specific game we can filter exactly.
const vg = settings.get().versionGroup;
const versions = new Set(snap.versionGroupByKey.get(vg)?.versions || []);
const holders = (d.held_by_pokemon || [])
.filter(
(h) =>
vg === 'all' ||
!versions.size ||
(h.version_details || []).some((vd) => versions.has(vd.version.name)),
)
.map((h) => snap.speciesById.get(idFromUrl(h.pokemon.url)))
.filter(Boolean)
.sort((a, b) => a.id - b.id);
const style = settings.get().spriteStyle;
const HOLDER_CAP = 400;
const holdersGrid = el('div', { class: 'ab-mons' });
function paintHolders(q) {
const qq = (q || '').trim().toLowerCase().replace(/\s+/g, '-');
const shown = qq ? holders.filter((sp) => sp.name.includes(qq)) : holders;
clear(holdersGrid);
if (!shown.length) {
holdersGrid.append(el('p', { class: 'detail__muted' }, 'No matches.'));
return;
}
for (const sp of shown.slice(0, HOLDER_CAP)) {
holdersGrid.append(
el(
'a',
{ class: 'ab-mon', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { style, alt: sp.name, size: 48 }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
);
}
if (shown.length > HOLDER_CAP) {
holdersGrid.append(
el('p', { class: 'detail__muted' }, `+${shown.length - HOLDER_CAP} more — filter to narrow.`),
);
}
}
paintHolders('');
const holderFilter = el('input', {
type: 'search',
class: 'search__input learners__filter',
placeholder: 'Filter Pokémon…',
oninput: (e) => paintHolders(e.target.value),
});
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el(
@ -63,9 +119,22 @@ export async function ItemDetail(id) {
d.attributes?.length
? fact('Attributes', d.attributes.map((a) => prettify(a.name)).join(', '))
: null,
fact('Held by', `${d.held_by_pokemon.length} Pokémon`),
fact('Held by', `${holders.length} Pokémon`),
),
el('section', { class: 'detail__section' }, el('h2', {}, 'Effect'), el('p', {}, effect)),
holders.length
? el(
'section',
{ class: 'detail__section' },
el(
'h2',
{},
`Held in the wild by${vg === 'all' ? '' : ` in ${prettify(vg)}`} (${holders.length})`,
),
holders.length > 24 ? holderFilter : null,
holdersGrid,
)
: null,
);
return view;
}

View File

@ -356,7 +356,20 @@ export async function PokemonDetail(nationalId) {
const evYield =
p.stats.filter((s) => s.effort > 0).map((s) => `${s.effort} ${STAT_LABEL[s.stat.name] || s.stat.name}`).join(', ') ||
'—';
const heldItems = p.held_items?.map((h) => prettify(h.item.name)).join(', ') || '—';
const heldItems = p.held_items?.length
? el(
'span',
{},
...p.held_items.flatMap((h, i) => {
const link = el(
'a',
{ class: 'link', href: `#/item/${idFromUrl(h.item.url)}` },
prettify(h.item.name),
);
return i === 0 ? [link] : [', ', link];
}),
)
: '—';
return el(
'div',
{},

94
src/views/ProgressView.js Normal file
View File

@ -0,0 +1,94 @@
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 { dexesForVersionGroup, versionGroupsByGeneration } from '../data/pokedex-resolver.js';
function bar(seen, caught, total) {
const pc = total ? (caught / total) * 100 : 0;
const ps = total ? (seen / total) * 100 : 0;
const track = el('div', { class: 'prog__track' });
const seenEl = el('div', { class: 'prog__seen' });
const caughtEl = el('div', { class: 'prog__caught' });
seenEl.style.width = `${ps}%`;
caughtEl.style.width = `${pc}%`;
track.append(seenEl, caughtEl);
return track;
}
export async function ProgressView() {
const view = el('section', { class: 'view lookup progressview' });
const snap = await loadSnapshot();
const speciesIdsFor = (vgKey) => {
const ids = new Set();
for (const dex of dexesForVersionGroup(snap, vgKey)) {
for (const [sid] of dex.entries) ids.add(sid);
}
return [...ids];
};
const list = el('div', { class: 'prog' });
function render() {
clear(list);
// National total
const nat = stats(snap.species.map((s) => s.id));
list.append(
el(
'div',
{ class: 'prog__row prog__row--nat' },
el('span', { class: 'prog__name' }, 'National Dex'),
bar(nat.seen, nat.caught, nat.total),
el('span', { class: 'prog__count' }, `${nat.caught} / ${nat.total}`),
),
);
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));
list.append(
el(
'button',
{
type: 'button',
class: 'prog__row',
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}`),
),
);
}
}
}
render();
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/settings' }, ' Settings')),
el(
'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',
{ class: 'prog__legend' },
el('span', { class: 'prog__key prog__key--caught' }),
' caught ',
el('span', { class: 'prog__key prog__key--seen' }),
' seen',
),
list,
);
const off = selection.subscribe(render);
onTeardown(view, () => off());
return view;
}

View File

@ -1,6 +1,7 @@
import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection } from '../store/selection.js';
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
export async function SettingsView() {
const view = el('section', { class: 'view settings' });
@ -136,8 +137,36 @@ export async function SettingsView() {
},
});
// Install (only when the browser has offered it and the app isn't
// already installed).
const installRow = el('div', { class: 'settings__install' });
function syncInstall() {
installRow.replaceChildren(
canInstall()
? el(
'button',
{
class: 'button',
type: 'button',
onclick: () => promptInstall(),
},
'Install app',
)
: el(
'p',
{ class: 'settings__note' },
window.matchMedia('(display-mode: standalone)').matches
? 'Running as an installed app.'
: 'Use your browser menu to add this Pokédex to your home screen.',
),
);
}
syncInstall();
const offInstall = onInstallChange(syncInstall);
view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
installRow,
el(
'div',
{ class: 'settings__group' },
@ -153,6 +182,12 @@ export async function SettingsView() {
),
el('h2', {}, 'Your data'),
el(
'p',
{ class: 'settings__note' },
el('a', { class: 'link', href: '#/progress' }, 'Progress by game'),
' — caught totals against every games dex.',
),
storageNote,
el('div', { class: 'settings__actions' },
el('button', {
@ -196,7 +231,9 @@ export async function SettingsView() {
),
);
onTeardown(view, () => {});
onTeardown(view, () => {
offInstall();
});
return view;
}

View File

@ -11,18 +11,11 @@ import { statAt, calcDamage } from '../lib/damage-calc.js';
import { NATURES } from '../data/natures.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { CompareTable } from '../components/CompareTable.js';
import { openPokemonPicker } from '../components/PokemonPicker.js';
const prettify = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
const STAT_ROWS = [
['hp', 'HP'],
['atk', 'Attack'],
['def', 'Defense'],
['spa', 'Sp. Atk'],
['spd', 'Sp. Def'],
['spe', 'Speed'],
];
const multClass = (m) =>
m === 0 ? 'm0' : m === 0.25 ? 'm025' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : m === 4 ? 'm4' : 'm1';
const multText = (m) => (m === 0.25 ? '¼' : m === 0.5 ? '½' : m === 1 ? '' : `${m}×`);
@ -72,7 +65,11 @@ export async function TeamView() {
'p',
{},
`Up to ${MAX_TEAM} Pokémon — check weaknesses, coverage and stats. `,
el('a', { class: 'link', href: '#/natures' }, 'Natures reference'),
el('a', { class: 'link', href: '#/natures' }, 'Natures'),
' · ',
el('a', { class: 'link', href: '#/types' }, 'Type chart'),
' · ',
el('a', { class: 'link', href: '#/compare' }, 'Compare'),
),
),
seg,
@ -309,73 +306,31 @@ export async function TeamView() {
'dl',
{ class: 'pfacts' },
fact('Average BST', String(avgBst)),
fact('Fastest', `${prettify(fastest.name)} (${fastest.stats?.spe ?? '?'} Spe)`),
fact('Fastest', monLink(fastest, `${fastest.stats?.spe ?? '?'} Spe`)),
fact(
'Bulkiest',
`${prettify(bulkiest.name)} (${
(bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)
} HP+Def+SpD)`,
monLink(
bulkiest,
`${(bulkiest.stats?.hp || 0) + (bulkiest.stats?.def || 0) + (bulkiest.stats?.spd || 0)} HP+Def+SpD`,
),
),
),
);
}
function monLink(sp, tail) {
return el(
'span',
{},
el('a', { class: 'link', href: `#/pokemon/${sp.id}` }, prettify(sp.name)),
` (${tail})`,
);
}
function compare(mem) {
const vg = snap.versionGroupByKey.get(settings.get().versionGroup);
const gen = vg ? vg.generation : 9;
const maxOf = (key) => Math.max(...mem.map((m) => m.stats?.[key] ?? 0));
const row = (label, cells, cls = '') =>
el('div', { class: `cmp__row ${cls}` }, el('span', { class: 'cmp__label' }, label), ...cells);
return el(
'div',
{ class: 'cmp' },
row(
'',
mem.map((sp) =>
el(
'a',
{ class: 'cmp__mem', href: `#/pokemon/${sp.id}` },
Sprite(sp.id, { size: 44, alt: sp.name }),
el('span', {}, sp.name.replace(/-/g, ' ')),
),
),
'cmp__row--head',
),
row(
'Type',
mem.map((sp) => el('span', { class: 'cmp__cell' }, ...typesForGen(sp, gen).map(TypeChip))),
),
...STAT_ROWS.map(([key, label]) => {
const mx = maxOf(key);
return row(
label,
mem.map((sp) => {
const v = sp.stats?.[key] ?? 0;
const bar = el('span', { class: 'cmp__bar' });
bar.style.width = `${(v / 200) * 100}%`;
return el(
'span',
{ class: `cmp__cell cmp__stat${v === mx ? ' is-max' : ''}` },
el('span', { class: 'cmp__val' }, String(v)),
el('span', { class: 'cmp__track' }, bar),
);
}),
);
}),
row(
'BST',
mem.map((sp) => {
const mx = Math.max(...mem.map((m) => m.bst || 0));
return el(
'span',
{ class: `cmp__cell cmp__stat${(sp.bst || 0) === mx ? ' is-max' : ''}` },
el('span', { class: 'cmp__val' }, String(sp.bst || 0)),
);
}),
'cmp__row--bst',
),
);
return CompareTable(mem, { gen, style: settings.get().spriteStyle });
}
// ---- Damage calculator ------------------------------------------

146
src/views/TypeChartView.js Normal file
View File

@ -0,0 +1,146 @@
import { el, clear } from '../lib/dom.js';
import { settings } from '../store/settings.js';
import { loadSnapshot } from '../data/snapshot.js';
import { prettify } from '../data/pokedex-resolver.js';
import { TYPES, multiplier, offensiveSummary, defensiveMatchups } from '../data/type-chart.js';
import { TypeChip } from '../components/TypeChip.js';
import { typeHex } from '../lib/type-color.js';
const ABBR = {
normal: 'NOR', fire: 'FIR', water: 'WAT', electric: 'ELE', grass: 'GRA', ice: 'ICE',
fighting: 'FIG', poison: 'POI', ground: 'GRD', flying: 'FLY', psychic: 'PSY', bug: 'BUG',
rock: 'ROC', ghost: 'GHO', dragon: 'DRA', dark: 'DRK', steel: 'STE', fairy: 'FAI',
};
const cellText = (m) => (m === 0 ? '0' : m === 0.5 ? '½' : m === 2 ? '2' : '');
const cellClass = (m) => (m === 0 ? 'm0' : m === 0.5 ? 'm05' : m === 2 ? 'm2' : 'm1');
export async function TypeChartView() {
const view = el('section', { class: 'view lookup tchartview' });
const snap = await loadSnapshot();
const st = settings.get();
const vg = snap.versionGroupByKey.get(st.versionGroup);
const gen = vg ? vg.generation : 9;
// Fairy is Gen 6+, Steel/Dark are Gen 2+.
const types = TYPES.filter((t) => {
if (t === 'fairy' && gen < 6) return false;
if ((t === 'steel' || t === 'dark') && gen < 2) return false;
return true;
});
let selected = null; // a type name, or null
const grid = el('div', { class: 'tchart' });
const summary = el('div', { class: 'tchart__summary' });
function select(t) {
selected = selected === t ? null : t;
paint();
}
function paint() {
grid.style.setProperty('--n', String(types.length));
clear(grid);
// corner
grid.append(el('span', { class: 'tchart__corner' }, 'ATK→\nDEF↓'));
// column headers (defending)
for (const def of types) {
grid.append(
el(
'button',
{
type: 'button',
class: `tchart__col${selected === def ? ' is-sel' : ''}`,
style: `--tc:${typeHex(def)}`,
title: prettify(def),
onclick: () => select(def),
},
ABBR[def],
),
);
}
// rows (attacking)
for (const atk of types) {
grid.append(
el(
'button',
{
type: 'button',
class: `tchart__row${selected === atk ? ' is-sel' : ''}`,
style: `--tc:${typeHex(atk)}`,
title: prettify(atk),
onclick: () => select(atk),
},
ABBR[atk],
),
);
for (const def of types) {
const m = multiplier(atk, [def], gen);
const hot = selected === atk || selected === def;
grid.append(
el(
'span',
{ class: `tchart__cell ${cellClass(m)}${hot ? ' is-hot' : ''}` },
cellText(m),
),
);
}
}
clear(summary);
if (!selected) {
summary.append(
el('p', { class: 'detail__muted' }, 'Tap a type on the edge to see everything it hits and everything that hits it.'),
);
return;
}
const inEra = (list) => (list || []).filter((t) => types.includes(t));
const off = offensiveSummary([selected], gen);
const def = defensiveMatchups([selected], gen);
const chipRow = (label, list) =>
list.length
? el('div', { class: 'tchart__srow' }, el('span', { class: 'tchart__slabel' }, label), el('span', { class: 'matchups__types' }, ...list.map(TypeChip)))
: null;
summary.append(
el('h3', { class: 'ppanel__sub' }, `${prettify(selected)} — attacking`),
chipRow('Super effective vs', inEra(off.strong)),
chipRow('Not very effective / no effect vs', inEra(off.walls)),
el('h3', { class: 'ppanel__sub' }, `${prettify(selected)} — defending`),
chipRow('Weak to', inEra(def['2'])),
chipRow('Resists', inEra(def['0.5'])),
chipRow('Immune to', inEra(def['0'])),
);
}
paint();
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/team' }, ' Team')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Type chart'),
el(
'p',
{},
st.versionGroup === 'all'
? 'Modern rules (Gen 6+).'
: `${prettify(st.versionGroup)} — Gen ${gen} rules${
gen < 6 ? ', no Fairy' : ''
}${gen < 2 ? ', no Steel/Dark' : ''}.`,
),
),
el(
'div',
{ class: 'tchart__legend' },
el('span', { class: 'tchart__cell m2' }, '2'),
' super effective ',
el('span', { class: 'tchart__cell m05' }, '½'),
' resisted ',
el('span', { class: 'tchart__cell m0' }, '0'),
' no effect',
),
el('div', { class: 'tchart__scroll' }, grid),
summary,
);
return view;
}

View File

@ -21,7 +21,11 @@ export default defineConfig({
// The bundled PokéAPI snapshot can be a few hundred KB.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
},
includeAssets: ['icon.svg', 'favicon.svg'],
includeAssets: [
'icon.svg',
'favicon.svg',
'apple-touch-icon.png',
],
manifest: {
name: 'Pokédex',
short_name: 'Pokédex',
@ -35,11 +39,34 @@ export default defineConfig({
theme_color: '#b3161a',
categories: ['games', 'reference', 'utilities'],
icons: [
{ src: 'icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
{ src: 'pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: 'pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: 'icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable',
src: 'pwa-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
shortcuts: [
{
name: 'Team builder',
short_name: 'Team',
url: './#/team',
icons: [{ src: 'pwa-192.png', sizes: '192x192' }],
},
{
name: 'Search',
short_name: 'Search',
url: './#/search',
icons: [{ src: 'pwa-192.png', sizes: '192x192' }],
},
{
name: 'Type chart',
short_name: 'Types',
url: './#/types',
icons: [{ src: 'pwa-192.png', sizes: '192x192' }],
},
],
},