Search moves: sort by TM/HM number; add an "HMs only" filter

TM/HM numbers are now baked into the snapshot (build-snapshot resolves
every machine id -> "TM25" etc. in a post-pass, ~2400 lookups), so the
Search moves list no longer lazily fetches /machine per row — it shows
numbers synchronously and can sort by them.

- New "TM / HM number" sort option, shown only while a machine filter is
  on, and dropped back to A–Z when neither is.
- "TMs only" now means strictly TMs; added a parallel "HMs only". Either
  or both. In "All games" mode a move can be a TM in one game and an HM
  in another, so machineFor picks the newest entry of a requested kind
  rather than just the newest entry (fixes "HMs only" showing 0).

Removes the getMachine/mapLimit lazy-resolution path (and its now-unused
stale-token guard) from SearchView.

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-08 10:44:14 -04:00
parent d73438dd29
commit fa302af7f1
4 changed files with 104 additions and 51 deletions

View File

@ -97,11 +97,12 @@ Working app with a type-themed UI:
applies Gen 1 / pre-Gen 6 rules.
- **Games** — overlay picker with stylised version-colour cover tiles,
sub-dex switch, and an "All games" (National, no gen limits) option.
- **Search** — tabbed lookup: Pokémon, Moves and Items, all browsable
offline from the snapshot. Moves filter by type / damage class / "TMs in
this game" and sort by power / accuracy / recency; Items filter by
category. Each has its own detail page. Query, scroll, tab and filters
persist.
- **Search** — tabbed lookup: Pokémon, Moves, Items and Abilities, all
browsable offline from the snapshot. Moves filter by type / damage class
/ "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. 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

View File

@ -335,7 +335,7 @@ async function main() {
pp: m.pp,
priority: m.priority,
generation: idFromUrl(m.generation.url),
// version-group -> machine id (resolve to TM/HM number on demand)
// version-group -> machine id; `tm` label filled in by the pass below
machines: (m.machines || []).map((x) => ({
vg: x.version_group.name,
id: idFromUrl(x.machine.url),
@ -344,6 +344,29 @@ async function main() {
});
moves.sort((a, b) => a.id - b.id);
// ---- TM / HM / TR numbers ---------------------------------------
// Resolve every machine id to its item name ("tm25" -> "TM25") so the
// search UI can show and sort by TM number without ~2000 lazy lookups.
const machineIds = [...new Set(moves.flatMap((m) => m.machines.map((x) => x.id)))];
console.log(`Fetching ${machineIds.length} TM/HM numbers …`);
const tmById = new Map();
await mapLimit(machineIds, CONCURRENCY, async (mid) => {
try {
const mc = await api(`machine/${mid}`);
const raw = mc.item?.name || '';
const mm = raw.match(/^([a-z]+)0*(\d+)$/i);
tmById.set(
mid,
mm ? `${mm[1].toUpperCase()}${String(Number(mm[2])).padStart(2, '0')}` : raw.toUpperCase(),
);
} catch {
/* leave unresolved */
}
});
for (const m of moves) {
for (const x of m.machines) x.tm = tmById.get(x.id) || null;
}
// ---- Abilities index ------------------------------------------
const abilityIndex = await api('ability?limit=100000');
console.log(`Fetching ${abilityIndex.results.length} abilities …`);

View File

@ -26,6 +26,7 @@ export const ui = createStore('pdx.ui', {
mvClass: '',
mvSort: 'name',
mvTm: false,
mvHm: false,
itCat: '',
itSort: 'name',
abSort: 'name',

View File

@ -4,7 +4,6 @@ import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { entry } from '../store/selection.js';
import { prettify } from '../data/pokedex-resolver.js';
import { getMachine, mapLimit } from '../data/api.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
@ -25,9 +24,12 @@ const TABS = [
{ id: 'abilities', label: 'Abilities' },
];
const tmLabel = (raw) => {
const m = raw.match(/^([a-z]+)0*(\d+)$/i);
return m ? `${m[1].toUpperCase()}${String(Number(m[2])).padStart(2, '0')}` : raw.toUpperCase();
/** Numeric sort key from a TM/HM/TR label like "TM25": TMs, then HMs, then TRs. */
const tmSortKey = (label) => {
if (!label) return 1e9;
const m = String(label).match(/^([A-Z]+)0*(\d+)$/);
if (!m) return 1e9 - 1;
return (({ TM: 0, HM: 1, TR: 2 })[m[1]] ?? 3) * 1000 + Number(m[2]);
};
export async function SearchView() {
@ -36,8 +38,6 @@ export async function SearchView() {
let tab = TABS.some((t) => t.id === ui.get().searchTab) ? ui.get().searchTab : 'pokemon';
let query = ui.get().searchQuery || '';
let enrichTimer = null;
let runToken = 0;
const input = el('input', {
class: 'search__input',
@ -85,6 +85,29 @@ export async function SearchView() {
);
}
// "TMs only" / "HMs only" checkboxes. Toggling either rebuilds the filter
// row so the sort dropdown gains/loses "TM / HM number", and drops that
// sort if it's active once neither is on.
function machineToggle(key, label) {
return el(
'label',
{ class: 'lookup__check' },
el('input', {
type: 'checkbox',
checked: !!ui.get()[key],
onchange: (e) => {
ui.set({ [key]: e.target.checked });
if (!ui.get().mvTm && !ui.get().mvHm && ui.get().mvSort === 'tm') {
ui.set({ mvSort: 'name' });
}
buildFilters();
run();
},
}),
label,
);
}
function buildFilters() {
clear(filters);
if (tab === 'moves') {
@ -104,26 +127,22 @@ export async function SearchView() {
),
pill(
'mvSort',
[['name', 'AZ'], ['power', 'Power'], ['accuracy', 'Accuracy'], ['gen', 'Newest']],
[
['name', 'AZ'],
['power', 'Power'],
['accuracy', 'Accuracy'],
['gen', 'Newest'],
// "By number" only makes sense with a TM/HM filter on.
...(ui.get().mvTm || ui.get().mvHm ? [['tm', 'TM / HM number']] : []),
],
ui.get().mvSort,
(v) => {
ui.set({ mvSort: v });
run();
},
),
el(
'label',
{ class: 'lookup__check' },
el('input', {
type: 'checkbox',
checked: !!ui.get().mvTm,
onchange: (e) => {
ui.set({ mvTm: e.target.checked });
run();
},
}),
'TMs only',
),
machineToggle('mvTm', 'TMs only'),
machineToggle('mvHm', 'HMs only'),
);
} else if (tab === 'items') {
filters.append(
@ -205,19 +224,41 @@ export async function SearchView() {
}
// ---- Moves (from snapshot, offline) -----------------------------
function renderMoves(q, token) {
function renderMoves(q) {
const st = settings.get();
const vg = st.versionGroup;
const type = ui.get().mvType;
const cls = ui.get().mvClass;
const sort = ui.get().mvSort;
const tmOnly = !!ui.get().mvTm;
const hmOnly = !!ui.get().mvHm;
const machineFilter = tmOnly || hmOnly;
const wantKinds = [tmOnly && 'TM', hmOnly && 'HM'].filter(Boolean);
const kindOf = (x) => (x.tm || '').slice(0, 2); // TM | HM | TR
// The machine entry to show / sort / filter by. For a specific game
// it's that game's entry; for National ("all") — where a move can be a
// TM in one game and an HM in another — it's the newest entry of a kind
// the filter is asking for. TM/HM numbers are baked into the snapshot.
const machineFor = (m) => {
if (vg !== 'all') return m.machines.find((x) => x.vg === vg) || null;
if (wantKinds.length) {
return [...m.machines].reverse().find((x) => wantKinds.includes(kindOf(x))) || null;
}
return m.machines[m.machines.length - 1] || null;
};
let list = snap.moves.filter((m) => {
if (q && !loose(m.name).includes(q)) return false;
if (type && m.type !== type) return false;
if (cls && m.damageClass !== cls) return false;
if (tmOnly && !m.machines.some((x) => vg === 'all' || x.vg === vg)) return false;
if (machineFilter) {
const e = machineFor(m);
if (!e) return false;
// machineFor already restricted the kind for National; for a
// specific game, check the game's own entry is the right kind.
if (vg !== 'all' && !wantKinds.includes(kindOf(e))) return false;
}
return true;
});
@ -225,16 +266,22 @@ export async function SearchView() {
if (sort === 'power') return (b.power ?? -1) - (a.power ?? -1) || a.name.localeCompare(b.name);
if (sort === 'accuracy') return (b.accuracy ?? -1) - (a.accuracy ?? -1) || a.name.localeCompare(b.name);
if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name);
if (sort === 'tm') {
return (
tmSortKey(machineFor(a)?.tm) - tmSortKey(machineFor(b)?.tm) ||
a.name.localeCompare(b.name)
);
}
return a.name.localeCompare(b.name);
});
note.textContent = `${list.length} move${list.length === 1 ? '' : 's'}`;
const shown = list.slice(0, 400);
const machineCells = new Map();
for (const m of shown) {
const tmCell = tmOnly ? el('span', { class: 'moverow__tm' }) : null;
if (tmCell) machineCells.set(m.id, { cell: tmCell, m });
const tmCell = machineFilter
? el('span', { class: 'moverow__tm' }, machineFor(m)?.tm || '')
: null;
results.append(
el(
'a',
@ -254,23 +301,6 @@ export async function SearchView() {
if (list.length > shown.length) {
results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`));
}
if (tmOnly && machineCells.size) {
clearTimeout(enrichTimer);
enrichTimer = setTimeout(() => {
mapLimit([...machineCells.values()], 6, async ({ cell, m }) => {
if (token !== runToken || !cell.isConnected) return;
const hit = m.machines.find((x) => x.vg === vg) || m.machines[m.machines.length - 1];
if (!hit) return;
try {
const mc = await getMachine(hit.id);
cell.textContent = tmLabel(mc.item.name);
} catch {
/* leave blank */
}
});
}, 200);
}
}
// ---- Items (from snapshot, offline) ---------------------------
@ -331,7 +361,6 @@ export async function SearchView() {
}
function run() {
const token = ++runToken;
const q = loose(query);
clear(results);
note.textContent = '';
@ -343,7 +372,7 @@ export async function SearchView() {
if (q) renderPokemon(q);
return;
}
if (tab === 'moves') renderMoves(q, token);
if (tab === 'moves') renderMoves(q);
else if (tab === 'items') renderItems(q);
else renderAbilities(q);
}
@ -351,7 +380,6 @@ export async function SearchView() {
run();
requestAnimationFrame(() => window.scrollTo(0, ui.get().searchScroll || 0));
onTeardown(view, () => {
clearTimeout(enrichTimer);
ui.set({ searchScroll: window.scrollY });
});