diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b5e8f5c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.6.0 diff --git a/package.json b/package.json index dd36daf..d88c5c1 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "node": ">=24.6.0", + "npm": ">=11.0.0" + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", diff --git a/src/api/pokemon.test.ts b/src/api/pokemon.test.ts index 91919a3..8cf96d5 100644 --- a/src/api/pokemon.test.ts +++ b/src/api/pokemon.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { getPokemonByID } from "./pokemon"; +import { getPokemonByID, getPokemonIdsByType } from "./pokemon"; import { DUMMY_DATA } from "../utils/testData"; // Nothing here is allowed to reach the network, so fetch is replaced @@ -45,6 +45,21 @@ describe("getPokemonByID", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + test("rejects id 0, there is no pokemon 0", async () => { + await expect(getPokemonByID(0)).rejects.toThrow("Invalid parameter"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("accepts the first valid id", async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => DUMMY_DATA[0] }); + + await getPokemonByID(1); + + expect(fetchMock).toHaveBeenCalledWith( + "https://pokeapi.co/api/v2/pokemon/1", + ); + }); + test("accepts the last valid id", async () => { fetchMock.mockResolvedValue({ ok: true, json: async () => DUMMY_DATA[0] }); @@ -55,3 +70,61 @@ describe("getPokemonByID", () => { ); }); }); + +describe("getPokemonIdsByType", () => { + function typeResponse(urls: string[]) { + return { + ok: true, + json: async () => ({ + pokemon: urls.map((url) => ({ pokemon: { url } })), + }), + }; + } + + test("pulls the ids out of the urls the api returns", async () => { + fetchMock.mockResolvedValue( + typeResponse([ + "https://pokeapi.co/api/v2/pokemon/25/", + "https://pokeapi.co/api/v2/pokemon/26/", + ]), + ); + + const ids = await getPokemonIdsByType("electric"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://pokeapi.co/api/v2/type/electric", + ); + expect(ids).toEqual([25, 26]); + }); + + test("returns the ids in order", async () => { + fetchMock.mockResolvedValue( + typeResponse([ + "https://pokeapi.co/api/v2/pokemon/135/", + "https://pokeapi.co/api/v2/pokemon/25/", + "https://pokeapi.co/api/v2/pokemon/81/", + ]), + ); + + expect(await getPokemonIdsByType("electric")).toEqual([25, 81, 135]); + }); + + test("drops the forms that sit above the highest id", async () => { + fetchMock.mockResolvedValue( + typeResponse([ + "https://pokeapi.co/api/v2/pokemon/25/", + "https://pokeapi.co/api/v2/pokemon/10080/", + ]), + ); + + expect(await getPokemonIdsByType("electric")).toEqual([25]); + }); + + test("throws when the api answers with an error", async () => { + fetchMock.mockResolvedValue({ ok: false, json: async () => ({}) }); + + await expect(getPokemonIdsByType("electric")).rejects.toThrow( + "Failed to fetch type", + ); + }); +}); diff --git a/src/api/pokemon.ts b/src/api/pokemon.ts index c97968f..8ca3348 100644 --- a/src/api/pokemon.ts +++ b/src/api/pokemon.ts @@ -10,3 +10,28 @@ export async function getPokemonByID(id: number) { return res.json(); } + +const MAX_POKEMON_ID = 1025; + +// Every pokemon of one type in a single request, so a type filter does not +// have to be found by fetching the whole pokedex and checking each one +export async function getPokemonIdsByType(type: string): Promise { + const res = await fetch(`https://pokeapi.co/api/v2/type/${type}`); + + if (!res.ok) { + throw new Error("Failed to fetch type"); + } + + const data = await res.json(); + + return (data.pokemon ?? []) + .map((entry: { pokemon: { url: string } }) => idFromUrl(entry.pokemon.url)) + .filter((id: number) => id >= 1 && id <= MAX_POKEMON_ID) + .sort((a: number, b: number) => a - b); +} + +// The api gives related resources as urls ending in the id +function idFromUrl(url: string): number { + const match = url.match(/\/(\d+)\/?$/); + return match ? Number(match[1]) : 0; +} diff --git a/src/controllers/pokemonController.test.ts b/src/controllers/pokemonController.test.ts index 542be27..f246e0d 100644 --- a/src/controllers/pokemonController.test.ts +++ b/src/controllers/pokemonController.test.ts @@ -6,6 +6,7 @@ import { GetPrev, GetNextFiltered, GetPrevFiltered, + resetTypeCache, } from "./pokemonController"; import * as pokemonApi from "../api/pokemon"; import { DUMMY_DATA } from "../utils/testData"; @@ -13,11 +14,13 @@ import type { FilterRules } from "../models/filters"; vi.mock("../api/pokemon", () => ({ getPokemonByID: vi.fn(), + getPokemonIdsByType: vi.fn(), })); describe("pokemonController", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); + resetTypeCache(); }); describe("matchesFilter", () => { @@ -94,16 +97,58 @@ describe("pokemonController", () => { expect(result).toEqual(DUMMY_DATA[1]); }); - it("should skip past the pokemon that do not match the rules", async () => { - vi.spyOn(pokemonApi, "getPokemonByID") - .mockResolvedValueOnce(DUMMY_DATA[0]) // grass, skipped - .mockResolvedValueOnce(DUMMY_DATA[1]) // fire, skipped - .mockResolvedValueOnce(DUMMY_DATA[2]); // electric, kept + it("should only fetch the members of the type it was asked for", async () => { + vi.spyOn(pokemonApi, "getPokemonIdsByType").mockResolvedValue([25]); + vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce( + DUMMY_DATA[2], + ); const result = await GetNextFiltered(0, { type: ["electric"] }); expect(result).toEqual(DUMMY_DATA[2]); - expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(3); + expect(pokemonApi.getPokemonIdsByType).toHaveBeenCalledWith("electric"); + // the 24 pokemon before pikachu are never asked for + expect(pokemonApi.getPokemonByID).toHaveBeenCalledTimes(1); + expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(25); + }); + + it("should merge the members when several types are chosen", async () => { + vi.spyOn(pokemonApi, "getPokemonIdsByType").mockImplementation( + async (type) => (type === "fire" ? [4] : [25]), + ); + vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce( + DUMMY_DATA[1], + ); + + const result = await GetNextFiltered(1, { type: ["electric", "fire"] }); + + // 4 comes before 25 once the two lists are merged and sorted + expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(4); + expect(result).toEqual(DUMMY_DATA[1]); + }); + + it("should look up each type once even across several calls", async () => { + const typeSpy = vi + .spyOn(pokemonApi, "getPokemonIdsByType") + .mockResolvedValue([25]); + vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValue(DUMMY_DATA[2]); + + await GetNextFiltered(0, { type: ["electric"] }); + await GetNextFiltered(0, { type: ["electric"] }); + + expect(typeSpy).toHaveBeenCalledTimes(1); + }); + + it("should walk the candidates backwards for GetPrevFiltered", async () => { + vi.spyOn(pokemonApi, "getPokemonIdsByType").mockResolvedValue([4, 25]); + vi.spyOn(pokemonApi, "getPokemonByID").mockResolvedValueOnce( + DUMMY_DATA[1], + ); + + const result = await GetPrevFiltered(25, { type: ["fire"] }); + + expect(pokemonApi.getPokemonByID).toHaveBeenCalledWith(4); + expect(result).toEqual(DUMMY_DATA[1]); }); it("should walk backwards for GetPrevFiltered", async () => { diff --git a/src/controllers/pokemonController.ts b/src/controllers/pokemonController.ts index cd7872b..78b07ef 100644 --- a/src/controllers/pokemonController.ts +++ b/src/controllers/pokemonController.ts @@ -1,4 +1,4 @@ -import { getPokemonByID } from "../api/pokemon"; +import { getPokemonByID, getPokemonIdsByType } from "../api/pokemon"; import type { FilterRules } from "../models/filters"; export type PokemonData = { @@ -93,6 +93,18 @@ export async function GetPrevFiltered( currentId: number, rules: FilterRules, ): Promise { + const candidates = await candidateIds(rules); + + if (candidates) { + for (let i = candidates.length - 1; i >= 0; i--) { + const id = candidates[i]; + if (id >= currentId) continue; + const pokemon = await fetchSafe(id); + if (pokemon && matchesFilter(pokemon, rules)) return pokemon; + } + return null; + } + let prevId = currentId - 1; while (prevId >= 1) { @@ -109,6 +121,17 @@ export async function GetNextFiltered( currentId: number, rules: FilterRules, ): Promise { + const candidates = await candidateIds(rules); + + if (candidates) { + for (const id of candidates) { + if (id <= currentId) continue; + const pokemon = await fetchSafe(id); + if (pokemon && matchesFilter(pokemon, rules)) return pokemon; + } + return null; + } + let nextId = currentId + 1; while (nextId <= MAX_POKEMON_ID) { @@ -120,3 +143,42 @@ export async function GetNextFiltered( } return null; } + +// The ids worth looking at for these rules, or null when every id qualifies. +// A type rule narrows it to the members of those types, which turns a walk +// through the whole pokedex into a walk through a short list +export async function candidateIds( + rules: FilterRules, +): Promise { + if (!rules.type || rules.type.length === 0) { + return null; + } + + const lists = await Promise.all(rules.type.map(idsForType)); + + // matchesFilter keeps a pokemon that has any of the chosen types, so the + // lists are merged rather than intersected + return [...new Set(lists.flat())].sort((a, b) => a - b); +} + +// The members of a type never change during a session, so the id list is +// held on to. The pokemon themselves are still fetched on demand +const typeIds = new Map(); + +// Only needed so one test cannot leave a type cached for the next +export function resetTypeCache() { + typeIds.clear(); +} + +async function idsForType(type: string): Promise { + const known = typeIds.get(type); + if (known) return known; + + try { + const ids = await getPokemonIdsByType(type); + typeIds.set(type, ids); + return ids; + } catch { + return []; + } +} diff --git a/src/index.css b/src/index.css deleted file mode 100644 index aad5cb1..0000000 --- a/src/index.css +++ /dev/null @@ -1,111 +0,0 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, "Segoe UI", Roboto, sans-serif; - --heading: system-ui, "Segoe UI", Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -body { - margin: 0; -} - -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -}