dex/scripts/build-snapshot.mjs
chris fa6df9d60b Sorting + richer snapshot; Legendary filter; shiny toggle to hero top
- Snapshot now carries per-species base stats + BST, height, weight, base
  EXP, capture rate, growth rate, colour, egg groups, gender rate,
  legendary/mythical/baby flags, and past typings (~72 KB gzip)
- Dex feed: Sort control (dex / name / BST / each stat / height / weight /
  base EXP / catch rate / recently caught) with a direction toggle;
  sort + filter persisted in the ui store; cards show the sorted metric
- New 'Legendary' filter chip (legendary or mythical) with a live count
- Detail hero: shiny toggle moved up beside the favourite, top of the hero

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 13:20:19 -04:00

235 lines
7.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Build a compact, offline-first data snapshot from PokéAPI.
*
* The app runs its list, search, sort, filters and game switching entirely
* off this file — no network until you open a detail page. It carries, per
* species: name, generation, current + past typings, the six base stats and
* their total, height, weight, base experience, and species flags
* (legendary / mythical / baby, capture rate, growth rate, colour, egg
* groups, gender rate). Plus every regional Pokédex and every version group.
*
* Richer detail (abilities, moves, flavour text, evolution, encounters) is
* fetched on demand by the app and cached by the service worker.
*
* Output: src/data/snapshot.json (imported by src/data/snapshot.js)
* Usage: node scripts/build-snapshot.mjs [--force]
*/
import { writeFile, mkdir, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(__dirname, '../src/data/snapshot.json');
const BASE = 'https://pokeapi.co/api/v2';
const MAX_AGE_DAYS = 30;
const FORCE = process.argv.includes('--force');
const CONCURRENCY = 16;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const pretty = (k) => k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const _cache = new Map();
async function api(path) {
if (_cache.has(path)) return _cache.get(path);
const url = path.startsWith('http') ? path : `${BASE}/${path}`;
let lastErr;
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch(url);
if (res.ok) {
const json = await res.json();
_cache.set(path, json);
return json;
}
if (res.status === 404) throw new Error(`404 ${url}`);
lastErr = new Error(`${res.status} ${url}`);
} catch (err) {
lastErr = err;
}
await sleep(400 * (attempt + 1));
}
throw lastErr;
}
async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let i = 0;
let done = 0;
const total = items.length;
const workers = Array.from({ length: Math.min(limit, total) }, async () => {
while (i < total) {
const idx = i++;
out[idx] = await fn(items[idx], idx);
if (++done % 200 === 0 || done === total) {
process.stdout.write(`\r${done}/${total} species`);
}
}
});
await Promise.all(workers);
if (total) process.stdout.write('\n');
return out;
}
async function isFresh() {
try {
const s = await stat(OUT);
return (Date.now() - s.mtimeMs) / 86_400_000 < MAX_AGE_DAYS;
} catch {
return false;
}
}
async function main() {
if (!FORCE && (await isFresh())) {
console.log(
`snapshot.json is fresh (< ${MAX_AGE_DAYS} days old). Use --force to rebuild.`,
);
return;
}
console.log('Building snapshot from PokéAPI …');
const t0 = Date.now();
// ---- Type name list ------------------------------------------------
const typeIndex = await api('type?limit=100');
const types = typeIndex.results
.map((r) => r.name)
.filter((n) => !['unknown', 'shadow', 'stellar'].includes(n));
// ---- Generations (for display names / regions) ------------------
const genIndex = await api('generation?limit=50');
const generations = [];
await mapLimit(genIndex.results, 8, async (g) => {
const data = await api(`generation/${g.name}`);
generations.push({
id: data.id,
name: pretty(data.main_region?.name || g.name),
region: data.main_region?.name || null,
});
});
generations.sort((a, b) => a.id - b.id);
// ---- Pokédexes ----------------------------------------------------
const dexIndex = await api('pokedex?limit=100');
const pokedexes = [];
await mapLimit(dexIndex.results, 8, async (d) => {
const data = await api(`pokedex/${d.name}`);
pokedexes.push({
key: data.name,
name: pretty(data.name),
region: data.region?.name || null,
isMainSeries: data.is_main_series,
versionGroups: data.version_groups.map((v) => v.name),
entries: data.pokemon_entries
.map((e) => [idFromUrl(e.pokemon_species.url), e.entry_number])
.sort((a, b) => a[1] - b[1]),
});
});
pokedexes.sort((a, b) => a.key.localeCompare(b.key));
// ---- Version groups (the "games") -----------------------------
const vgIndex = await api('version-group?limit=100');
const versionGroups = [];
await mapLimit(vgIndex.results, 8, async (v) => {
const data = await api(`version-group/${v.name}`);
versionGroups.push({
key: data.name,
name: pretty(data.name),
generation: idFromUrl(data.generation.url),
versions: data.versions.map((x) => x.name),
pokedexKeys: data.pokedexes.map((x) => x.name),
});
});
versionGroups.sort(
(a, b) => a.generation - b.generation || a.key.localeCompare(b.key),
);
// ---- Species: the data-rich part -----------------------------
const speciesIndex = await api('pokemon-species?limit=100000');
const ids = speciesIndex.results
.map((r) => idFromUrl(r.url))
.sort((a, b) => a - b);
console.log(`Fetching ${ids.length} species (stats, typings, flags) …`);
const species = await mapLimit(ids, CONCURRENCY, async (id) => {
const [pk, sp] = await Promise.all([
api(`pokemon/${id}`),
api(`pokemon-species/${id}`),
]);
const s = Object.fromEntries(pk.stats.map((x) => [x.stat.name, x.base_stat]));
const stats = {
hp: s.hp ?? 0,
atk: s.attack ?? 0,
def: s.defense ?? 0,
spa: s['special-attack'] ?? 0,
spd: s['special-defense'] ?? 0,
spe: s.speed ?? 0,
};
return {
id,
name: sp.name,
generation: idFromUrl(sp.generation.url),
types: pk.types
.slice()
.sort((a, b) => a.slot - b.slot)
.map((t) => t.type.name),
pastTypes: (pk.past_types || []).map((p) => ({
gen: idFromUrl(p.generation.url),
types: p.types.slice().sort((a, b) => a.slot - b.slot).map((t) => t.type.name),
})),
stats,
bst: stats.hp + stats.atk + stats.def + stats.spa + stats.spd + stats.spe,
height: pk.height,
weight: pk.weight,
baseExp: pk.base_experience ?? null,
isLegendary: sp.is_legendary,
isMythical: sp.is_mythical,
isBaby: sp.is_baby,
captureRate: sp.capture_rate,
baseHappiness: sp.base_happiness,
growthRate: sp.growth_rate?.name || null,
color: sp.color?.name || null,
eggGroups: sp.egg_groups.map((g) => g.name),
genderRate: sp.gender_rate,
};
});
// ---- Write ----------------------------------------------------
const snapshot = {
meta: {
generatedAt: new Date().toISOString(),
source: BASE,
counts: {
species: species.length,
pokedexes: pokedexes.length,
versionGroups: versionGroups.length,
},
},
types,
generations,
species,
pokedexes,
versionGroups,
};
await mkdir(dirname(OUT), { recursive: true });
const body = JSON.stringify(snapshot);
await writeFile(OUT, body);
console.log(
`Wrote ${OUT}${species.length} species, ${pokedexes.length} pokédexes, ` +
`${versionGroups.length} version groups (${(body.length / 1024).toFixed(0)} KB) in ` +
`${((Date.now() - t0) / 1000).toFixed(1)}s`,
);
}
main().catch((err) => {
console.error('\nSnapshot build failed:', err.message);
console.error(
'If you are offline, commit a previously generated src/data/snapshot.json ' +
'or retry when you have a connection.',
);
process.exit(1);
});