diff --git a/changes/stats-anilist-title-search.md b/changes/stats-anilist-title-search.md new file mode 100644 index 00000000..23e0c02f --- /dev/null +++ b/changes/stats-anilist-title-search.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Fixed manual AniList linking from the stats anime page so automatic searches drop the generated `Season N` suffix and search only the anime title. diff --git a/stats/bun.lock b/stats/bun.lock index 91148e8e..c462c916 100644 --- a/stats/bun.lock +++ b/stats/bun.lock @@ -16,6 +16,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.4.0", + "happy-dom": "^20.10.2", "tailwindcss": "^4.0.0", "typescript": "^5.9.0", "vite": "^6.3.0", @@ -239,16 +240,24 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -291,6 +300,8 @@ "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -307,6 +318,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.10.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -399,12 +412,18 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="], diff --git a/stats/package.json b/stats/package.json index cbe3d712..3b537aa5 100644 --- a/stats/package.json +++ b/stats/package.json @@ -15,11 +15,12 @@ "recharts": "^2.15.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.4.0", + "happy-dom": "^20.10.2", "tailwindcss": "^4.0.0", - "@tailwindcss/vite": "^4.0.0", "typescript": "^5.9.0", "vite": "^6.3.0" } diff --git a/stats/src/components/anime/AnilistSelector.test.tsx b/stats/src/components/anime/AnilistSelector.test.tsx new file mode 100644 index 00000000..7c390ac5 --- /dev/null +++ b/stats/src/components/anime/AnilistSelector.test.tsx @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Window } from 'happy-dom'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { apiClient } from '../../lib/api-client'; +import { AnilistSelector } from './AnilistSelector'; + +interface TestWindow extends Window { + IS_REACT_ACT_ENVIRONMENT?: boolean; +} + +function installDom(): () => void { + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + const previousHTMLElement = globalThis.HTMLElement; + const previousISReactActEnvironment = ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT; + const window = new Window() as TestWindow; + + Object.defineProperty(globalThis, 'window', { value: window, configurable: true }); + Object.defineProperty(globalThis, 'document', { value: window.document, configurable: true }); + Object.defineProperty(globalThis, 'HTMLElement', { + value: window.HTMLElement, + configurable: true, + }); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + + return () => { + Object.defineProperty(globalThis, 'window', { + value: previousWindow, + configurable: true, + }); + Object.defineProperty(globalThis, 'document', { + value: previousDocument, + configurable: true, + }); + Object.defineProperty(globalThis, 'HTMLElement', { + value: previousHTMLElement, + configurable: true, + }); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = previousISReactActEnvironment; + }; +} + +function renderSelector(root: Root, props: { animeId: number; initialQuery: string }): void { + root.render( + {}} + onLinked={() => {}} + />, + ); +} + +function inputValue(container: Element): string { + const input = container.querySelector('input'); + assert.ok(input); + return input.value; +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test('AnilistSelector resyncs normalized query and searches when the initial anime changes', async () => { + const uninstallDom = installDom(); + const originalSearchAnilist = apiClient.searchAnilist; + const secondSearch = deferred>>(); + const searchCalls: string[] = []; + + apiClient.searchAnilist = (async (query: string) => { + searchCalls.push(query); + if (query === 'My Hero Academia') { + return secondSearch.promise; + } + return [ + { + id: 1, + episodes: 1, + season: null, + seasonYear: null, + description: null, + coverImage: null, + title: { romaji: 'First Result', english: null, native: null }, + }, + ]; + }) as typeof apiClient.searchAnilist; + + try { + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + await act(async () => { + renderSelector(root, { animeId: 1, initialQuery: 'Sword Art Online Season 1' }); + }); + + assert.equal(inputValue(container), 'Sword Art Online'); + assert.deepEqual(searchCalls, ['Sword Art Online']); + assert.match(container.textContent ?? '', /First Result/); + + await act(async () => { + renderSelector(root, { animeId: 2, initialQuery: 'My Hero Academia: Season 3' }); + }); + + assert.equal(inputValue(container), 'My Hero Academia'); + assert.deepEqual(searchCalls, ['Sword Art Online', 'My Hero Academia']); + assert.doesNotMatch(container.textContent ?? '', /First Result/); + assert.match(container.textContent ?? '', /Searching/); + + await act(async () => { + secondSearch.resolve([ + { + id: 2, + episodes: 2, + season: null, + seasonYear: null, + description: null, + coverImage: null, + title: { romaji: 'Second Result', english: null, native: null }, + }, + ]); + await secondSearch.promise; + }); + + assert.match(container.textContent ?? '', /Second Result/); + + await act(async () => { + root.unmount(); + }); + } finally { + apiClient.searchAnilist = originalSearchAnilist; + uninstallDom(); + } +}); diff --git a/stats/src/components/anime/AnilistSelector.tsx b/stats/src/components/anime/AnilistSelector.tsx index d648d72a..419107d5 100644 --- a/stats/src/components/anime/AnilistSelector.tsx +++ b/stats/src/components/anime/AnilistSelector.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { apiClient } from '../../lib/api-client'; +import { normalizeAnilistSearchQuery } from '../../lib/anilist-search-query'; interface AnilistMedia { id: number; @@ -24,7 +25,7 @@ export function AnilistSelector({ onClose, onLinked, }: AnilistSelectorProps) { - const [query, setQuery] = useState(initialQuery); + const [query, setQuery] = useState(() => normalizeAnilistSearchQuery(initialQuery)); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [linking, setLinking] = useState(null); @@ -33,17 +34,24 @@ export function AnilistSelector({ useEffect(() => { inputRef.current?.focus(); - if (initialQuery) doSearch(initialQuery); - }, []); + const normalizedInitialQuery = normalizeAnilistSearchQuery(initialQuery); + setQuery(normalizedInitialQuery); + setResults([]); + setLoading(false); + setLinking(null); + if (debounceRef.current) clearTimeout(debounceRef.current); + if (normalizedInitialQuery) doSearch(normalizedInitialQuery); + }, [initialQuery, animeId]); const doSearch = async (q: string) => { - if (!q.trim()) { + const searchQuery = normalizeAnilistSearchQuery(q); + if (!searchQuery) { setResults([]); return; } setLoading(true); try { - const data = await apiClient.searchAnilist(q.trim()); + const data = await apiClient.searchAnilist(searchQuery); setResults(data); } catch { setResults([]); diff --git a/stats/src/lib/anilist-search-query.test.ts b/stats/src/lib/anilist-search-query.test.ts new file mode 100644 index 00000000..ac51cd6c --- /dev/null +++ b/stats/src/lib/anilist-search-query.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { normalizeAnilistSearchQuery } from './anilist-search-query'; + +test('normalizeAnilistSearchQuery removes appended season scope from anime titles', () => { + assert.equal(normalizeAnilistSearchQuery('Sword Art Online Season 1'), 'Sword Art Online'); + assert.equal(normalizeAnilistSearchQuery('KonoSuba Season 02'), 'KonoSuba'); +}); + +test('normalizeAnilistSearchQuery removes bracketed season scope without dropping real title text', () => { + assert.equal(normalizeAnilistSearchQuery('KonoSuba (Season 2)'), 'KonoSuba'); + assert.equal(normalizeAnilistSearchQuery('KonoSuba - Season 2'), 'KonoSuba'); +}); + +test('normalizeAnilistSearchQuery removes colon-delimited season scope from anime titles', () => { + assert.equal(normalizeAnilistSearchQuery('My Hero Academia: Season 3'), 'My Hero Academia'); + assert.equal(normalizeAnilistSearchQuery('Title: Season 01'), 'Title'); +}); + +test('normalizeAnilistSearchQuery keeps inputs when stripping season scope would erase title', () => { + assert.equal(normalizeAnilistSearchQuery('Season 1'), 'Season 1'); +}); diff --git a/stats/src/lib/anilist-search-query.ts b/stats/src/lib/anilist-search-query.ts new file mode 100644 index 00000000..c54b3bb5 --- /dev/null +++ b/stats/src/lib/anilist-search-query.ts @@ -0,0 +1,9 @@ +export function normalizeAnilistSearchQuery(query: string): string { + const trimmed = query.trim().replace(/\s+/g, ' '); + const withoutSeason = trimmed + .replace(/\s*[\[(]\s*Season\s+0?\d+\s*[\])]\s*$/i, '') + .replace(/\s*[-:]\s*Season\s+0?\d+\s*$/i, '') + .replace(/\s+Season\s+0?\d+\s*$/i, '') + .trim(); + return withoutSeason.length > 0 ? withoutSeason : trimmed; +}