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
This commit is contained in:
chris 2026-09-09 17:44:42 -04:00
parent 86e9bc60bc
commit 9810eb0996
2 changed files with 84 additions and 2 deletions

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',
{},