Dex grid: ability, egg group, min BST and fully-evolved filters

Extends the existing filter drawer (type/gen) with four more:

- Ability — snapshot abilities already carry a reverse pokemon[] index,
  so this is a plain lookup, no new data needed.
- Egg group — options derived from the species list's own eggGroups.
- Min BST — a number field; hard filter, distinct from sorting by BST.
- Fully evolved only — new species.evolvesFromId field (from
  pokemon-species' evolves_from_species, already fetched during the
  snapshot build, so no extra requests). "Fully evolved" is computed
  client-side as "no other species points here via evolvesFromId" —
  correctly handles branching lines (Eevee's evolutions all count as
  fully evolved; Eevee itself doesn't).

All four persist via the ui store and count toward the filter badge, same
as the existing filters.

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-04 12:14:36 -04:00
parent fecafe139c
commit c9807127d6
5 changed files with 135 additions and 2 deletions

View File

@ -70,7 +70,10 @@ src/
Working app with a type-themed UI:
- **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number),
per-dex progress, name/number filter, caught/favorite filters.
per-dex progress, name/number filter, caught/favorite filters, plus a
filter drawer: type, generation, ability, egg group, minimum BST, and
"fully evolved only" (derived client-side from each species' evolves-from
link — no extra fetches).
- **Forms** — Megas, Gigantamax, regional forms and alternate formes in the
snapshot; a pill switcher on the detail page rebuilds types / stats /
matchups / abilities / learnset / artwork for the chosen form. Purely

View File

@ -312,6 +312,11 @@ async function main() {
color: sp.color?.name || null,
eggGroups: sp.egg_groups.map((g) => g.name),
genderRate: sp.gender_rate,
// Species this evolves FROM (null for base forms) — lets the app
// derive "fully evolved" client-side without fetching every
// evolution chain: a species nothing points to via this field has no
// further evolution.
evolvesFromId: sp.evolves_from_species ? idFromUrl(sp.evolves_from_species.url) : null,
};
});

View File

@ -15,6 +15,10 @@ export const ui = createStore('pdx.ui', {
filter: 'all',
filterType: '',
filterGen: 0,
filterAbility: 0,
filterEggGroup: '',
filterMinBst: 0,
filterFullyEvolved: false,
recent: [],
toolsOpen: false,
teamMode: 'coverage',

View File

@ -1837,6 +1837,29 @@
max-width: none;
flex: 1;
}
.feed-minbst {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
padding: 7px 12px;
border: 1.5px solid var(--border);
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
color: var(--text-dim);
white-space: nowrap;
}
.feed-minbst input {
width: 4.5em;
min-width: 0;
border: none;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 700;
padding: 0;
}
.recent {
display: flex;
align-items: center;

View File

@ -82,6 +82,10 @@ export async function DexGrid() {
let filterKey = ui.get().filter || 'all';
let filterType = ui.get().filterType || '';
let filterGen = Number(ui.get().filterGen) || 0;
let filterAbility = Number(ui.get().filterAbility) || 0;
let filterEggGroup = ui.get().filterEggGroup || '';
let filterMinBst = Number(ui.get().filterMinBst) || 0;
let filterFullyEvolved = !!ui.get().filterFullyEvolved;
let sortKey = ui.get().sort || 'dex';
let sortDesc = !!ui.get().sortDesc;
let rows = [];
@ -210,6 +214,87 @@ export async function DexGrid() {
'🎲',
);
// "Fully evolved" = no other species lists this one as evolvesFromId —
// derived client-side rather than fetching every evolution chain.
const hasNextEvo = new Set(snap.species.map((s) => s.evolvesFromId).filter(Boolean));
const isFullyEvolved = (sp) => !hasNextEvo.has(sp.id);
const eggGroupList = [...new Set(snap.species.flatMap((s) => s.eggGroups || []))].sort();
const abilityList = snap.abilities.slice().sort((a, b) => a.name.localeCompare(b.name));
const abilitySelect = el(
'select',
{
class: 'feed-sort__select',
onchange: (e) => {
filterAbility = Number(e.target.value) || 0;
ui.set({ filterAbility });
paintGrid();
resetScroll();
syncToolsBadge();
},
},
el('option', { value: '0' }, 'Any ability'),
...abilityList.map((a) =>
el('option', { value: String(a.id), selected: a.id === filterAbility }, prettify(a.name)),
),
);
const eggGroupSelect = el(
'select',
{
class: 'feed-sort__select',
onchange: (e) => {
filterEggGroup = e.target.value;
ui.set({ filterEggGroup });
paintGrid();
resetScroll();
syncToolsBadge();
},
},
el('option', { value: '' }, 'Any egg group'),
...eggGroupList.map((g) => el('option', { value: g, selected: g === filterEggGroup }, prettify(g))),
);
const minBstField = el(
'label',
{ class: 'feed-minbst' },
el('span', {}, 'Min BST'),
el('input', {
type: 'number',
min: '0',
max: '1000',
step: '10',
placeholder: '0',
value: filterMinBst || '',
onchange: (e) => {
filterMinBst = Math.max(0, Number(e.target.value) || 0);
e.target.value = filterMinBst || '';
ui.set({ filterMinBst });
paintGrid();
resetScroll();
syncToolsBadge();
},
}),
);
const fullyEvolvedCheck = el(
'label',
{ class: 'lookup__check' },
el('input', {
type: 'checkbox',
checked: filterFullyEvolved,
onchange: (e) => {
filterFullyEvolved = e.target.checked;
ui.set({ filterFullyEvolved });
paintGrid();
resetScroll();
syncToolsBadge();
},
}),
'Fully evolved only',
);
// Changing the list order/contents makes the old scroll offset meaningless.
function resetScroll() {
ui.set({ feedScroll: 0 });
@ -263,6 +348,8 @@ export async function DexGrid() {
filterBar,
el('div', { class: 'feed-sort' }, el('span', { class: 'feed-sort__label' }, 'Sort'), sortSelect, dirBtn),
el('div', { class: 'feed-sort feed-filters' }, typeSelect, genSelect, randomBtn),
el('div', { class: 'feed-sort feed-filters' }, abilitySelect, eggGroupSelect, minBstField),
el('div', { class: 'feed-sort feed-filters' }, fullyEvolvedCheck),
);
const toolsToggle = el(
'button',
@ -281,7 +368,13 @@ export async function DexGrid() {
);
function syncToolsBadge() {
const n =
(filterKey !== 'all' ? 1 : 0) + (filterType ? 1 : 0) + (filterGen ? 1 : 0);
(filterKey !== 'all' ? 1 : 0) +
(filterType ? 1 : 0) +
(filterGen ? 1 : 0) +
(filterAbility ? 1 : 0) +
(filterEggGroup ? 1 : 0) +
(filterMinBst ? 1 : 0) +
(filterFullyEvolved ? 1 : 0);
toolsToggle.dataset.active = n ? String(n) : '';
}
syncToolsBadge();
@ -337,6 +430,7 @@ export async function DexGrid() {
const gen = snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9;
const boxed = st.spriteStyle === 'game' && gen <= 2;
const filterTest = FILTERS.find((f) => f.key === filterKey).test;
const abilityMon = filterAbility ? new Set(snap.abilityById.get(filterAbility)?.pokemon || []) : null;
let list = rows.filter(({ species, number }) => {
if (
@ -349,6 +443,10 @@ export async function DexGrid() {
}
if (filterGen && species.generation !== filterGen) return false;
if (filterType && !typesForGen(species, gen).includes(filterType)) return false;
if (abilityMon && !abilityMon.has(species.id)) return false;
if (filterEggGroup && !(species.eggGroups || []).includes(filterEggGroup)) return false;
if (filterMinBst && (species.bst || 0) < filterMinBst) return false;
if (filterFullyEvolved && !isFullyEvolved(species)) return false;
const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false };
return filterTest(e, species);
});