fix(stats): review fuzzy AniList duplicates before merging

- Preserve merged title aliases for future episodes
- Fail closed when queued writes cannot drain
This commit is contained in:
2026-08-11 23:11:39 -07:00
parent d82a4fd8c6
commit d8c5fc321a
28 changed files with 1625 additions and 44 deletions
@@ -0,0 +1,232 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { AnimeLibraryItem } from '../../types/stats';
import { AnimeMergeDialog } from './AnimeMergeDialog';
import { LibraryEntryPicker } from './LibraryEntryPicker';
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 libraryItem(animeId: number, title: string): AnimeLibraryItem {
return {
animeId,
canonicalTitle: title,
anilistId: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
totalTokensSeen: 0,
episodeCount: 1,
episodesTotal: null,
lastWatchedMs: 1,
};
}
test('AnimeMergeDialog focuses its close control, closes on Escape, and restores focus', async () => {
const uninstallDom = installDom();
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
function Harness() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Review merge
</button>
{open ? (
<AnimeMergeDialog
entries={[libraryItem(1, 'Show'), libraryItem(2, 'Show Season 1')]}
onClose={() => setOpen(false)}
onMerged={() => undefined}
/>
) : null}
</>
);
}
await act(async () => root.render(<Harness />));
const trigger = container.querySelector('button') as HTMLButtonElement;
trigger.focus();
await act(async () => trigger.click());
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Close');
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
});
assert.equal(container.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
await act(async () => root.unmount());
} finally {
uninstallDom();
}
});
test('AnimeMergeDialog keeps keyboard focus inside the modal', async () => {
const uninstallDom = installDom();
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
<AnimeMergeDialog
entries={[libraryItem(1, 'Show'), libraryItem(2, 'Show Season 1')]}
onClose={() => undefined}
onMerged={() => undefined}
/>,
);
});
const dialog = container.querySelector('[role="dialog"]') as HTMLElement;
const focusable = [...dialog.querySelectorAll('button:not([disabled])')] as HTMLButtonElement[];
const first = focusable[0];
const last = focusable.at(-1);
assert.ok(first);
assert.ok(last);
last.focus();
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Tab' }));
});
assert.equal(document.activeElement, first);
first.focus();
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }));
});
assert.equal(document.activeElement, last);
await act(async () => root.unmount());
} finally {
uninstallDom();
}
});
test('LibraryEntryPicker focuses search, closes on Escape, and restores focus', async () => {
const uninstallDom = installDom();
const original = apiClient.getAnimeLibrary;
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show'),
]) as typeof apiClient.getAnimeLibrary;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
function Harness() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Move
</button>
{open ? (
<LibraryEntryPicker
heading="Move episode"
onSelect={() => undefined}
onClose={() => setOpen(false)}
/>
) : null}
</>
);
}
await act(async () => root.render(<Harness />));
const trigger = container.querySelector('button') as HTMLButtonElement;
trigger.focus();
await act(async () => trigger.click());
assert.equal(document.activeElement?.getAttribute('placeholder'), 'Search library...');
await act(async () => {
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
});
assert.equal(container.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
await act(async () => root.unmount());
} finally {
apiClient.getAnimeLibrary = original;
uninstallDom();
}
});
test('LibraryEntryPicker cannot be dismissed while a move is in flight', async () => {
const uninstallDom = installDom();
const original = apiClient.getAnimeLibrary;
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show'),
]) as typeof apiClient.getAnimeLibrary;
let closeCalls = 0;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
<LibraryEntryPicker
heading="Move episode"
busyAnimeId={1}
onSelect={() => undefined}
onClose={() => {
closeCalls += 1;
}}
/>,
);
});
const closeButton = container.querySelector('button[aria-label="Close"]') as HTMLButtonElement;
assert.equal(closeButton.disabled, true);
await act(async () => {
closeButton.click();
container.firstElementChild?.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
});
assert.equal(closeCalls, 0);
await act(async () => root.unmount());
} finally {
apiClient.getAnimeLibrary = original;
uninstallDom();
}
});
@@ -1,6 +1,7 @@
import { useId, useState } from 'react';
import { useId, useRef, useState } from 'react';
import { apiClient } from '../../lib/api-client';
import { formatDuration, formatNumber } from '../../lib/formatters';
import { useModalFocus } from '../../hooks/useModalFocus';
import { AnimeCoverImage } from './AnimeCoverImage';
import type { AnimeLibraryItem } from '../../types/stats';
@@ -20,6 +21,8 @@ function pickDefaultKeeper(entries: AnimeLibraryItem[]): number {
export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialogProps) {
const headingId = useId();
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const [keeperId, setKeeperId] = useState(() => pickDefaultKeeper(entries));
const [merging, setMerging] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -28,6 +31,13 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
const totalCards = entries.reduce((sum, entry) => sum + entry.totalCards, 0);
const totalActiveMs = entries.reduce((sum, entry) => sum + entry.totalActiveMs, 0);
useModalFocus({
dialogRef,
initialFocusRef: closeButtonRef,
dismissDisabled: merging,
onDismiss: onClose,
});
const handleMerge = async () => {
const sourceAnimeIds = entries
.map((entry) => entry.animeId)
@@ -58,6 +68,7 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
@@ -70,6 +81,7 @@ export function AnimeMergeDialog({ entries, onClose, onMerged }: AnimeMergeDialo
Merge {entries.length} Library Entries
</h3>
<button
ref={closeButtonRef}
type="button"
onClick={handleDismiss}
disabled={merging}
@@ -161,3 +161,256 @@ test('AnimeTab merges the selected duplicate entries into the chosen keeper', as
uninstallDom();
}
});
test('AnimeTab keeps a suggested duplicate visible until it is reviewed and merged', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
mergeAnime: apiClient.mergeAnime,
};
let entries = [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
let recommendations = [{ recommendationId: 41, animeIds: [1, 2] }];
let mergeCall: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
apiClient.getAnimeLibrary = (async () => entries) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => ({
recommendations,
})) as typeof apiClient.getAnimeMergeRecommendations;
apiClient.dismissAnimeMergeRecommendation = (async () =>
undefined) as typeof apiClient.dismissAnimeMergeRecommendation;
apiClient.mergeAnime = (async (targetAnimeId: number, sourceAnimeIds: number[]) => {
mergeCall = { targetAnimeId, sourceAnimeIds };
entries = [libraryItem(targetAnimeId, 'Show', 3)];
recommendations = [];
return {
ok: true,
animeId: targetAnimeId,
mergedAnimeIds: sourceAnimeIds,
movedVideos: 1,
} satisfies StatsMergeAnimeResponse;
}) as typeof apiClient.mergeAnime;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(<AnimeTab />);
});
assert.match(container.textContent ?? '', /Possible duplicate/);
assert.match(container.textContent ?? '', /Show/);
assert.match(container.textContent ?? '', /Show Season 1/);
await act(async () => {
findButton(container, 'Review merge').click();
});
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
const keeper = [...container.querySelectorAll('button[aria-pressed]')].find((button) =>
(button.textContent ?? '').includes('Show Season 1'),
) as HTMLButtonElement | undefined;
assert.ok(keeper);
await act(async () => {
keeper.click();
});
await act(async () => {
findButton(container, 'Merge Entries').click();
});
assert.deepEqual(mergeCall, { targetAnimeId: 2, sourceAnimeIds: [1] });
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
assert.doesNotMatch(container.textContent ?? '', /Show Season 1/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
test('AnimeTab dismisses a false-positive duplicate recommendation', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
};
let dismissedId: number | null = null;
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show', 2),
libraryItem(2, 'Different Show', 1),
]) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => ({
recommendations: [{ recommendationId: 73, animeIds: [1, 2] }],
})) as typeof apiClient.getAnimeMergeRecommendations;
apiClient.dismissAnimeMergeRecommendation = (async (recommendationId: number) => {
dismissedId = recommendationId;
}) as typeof apiClient.dismissAnimeMergeRecommendation;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(<AnimeTab />));
await act(async () => {
findButton(container, 'Not duplicates').click();
});
assert.equal(dismissedId, 73);
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
test('AnimeTab refreshes the library and recommendations when the window regains focus', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
};
let entries = [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
let libraryFetches = 0;
let recommendationFetches = 0;
apiClient.getAnimeLibrary = (async () => {
libraryFetches += 1;
return entries;
}) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => {
recommendationFetches += 1;
return { recommendations: [] };
}) as typeof apiClient.getAnimeMergeRecommendations;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(<AnimeTab />));
entries = [libraryItem(1, 'Show', 3)];
await act(async () => {
window.dispatchEvent(new window.Event('focus'));
});
assert.equal(libraryFetches, 2);
assert.equal(recommendationFetches, 2);
assert.doesNotMatch(container.textContent ?? '', /Show Season 1/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
test('AnimeTab keeps a recommendation visible through a transient refresh failure', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
};
let failRecommendations = false;
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show', 2),
libraryItem(2, 'Show Season 1', 1),
]) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => {
if (failRecommendations) throw new Error('temporary failure');
return { recommendations: [{ recommendationId: 41, animeIds: [1, 2] }] };
}) as typeof apiClient.getAnimeMergeRecommendations;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(<AnimeTab />));
assert.match(container.textContent ?? '', /Possible duplicate/);
failRecommendations = true;
await act(async () => window.dispatchEvent(new window.Event('focus')));
assert.match(container.textContent ?? '', /Possible duplicate/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
test('AnimeTab retains a recommendation and reports a failed dismissal', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
};
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show', 2),
libraryItem(2, 'Show Season 1', 1),
]) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => ({
recommendations: [{ recommendationId: 41, animeIds: [1, 2] }],
})) as typeof apiClient.getAnimeMergeRecommendations;
apiClient.dismissAnimeMergeRecommendation = (async () => {
throw new Error('offline');
}) as typeof apiClient.dismissAnimeMergeRecommendation;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(<AnimeTab />));
await act(async () => findButton(container, 'Not duplicates').click());
assert.match(container.textContent ?? '', /Possible duplicate/);
assert.match(container.textContent ?? '', /Could not dismiss this suggestion/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
test('AnimeTab keeps an open recommendation review stable during background refresh', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
};
let recommendations = [{ recommendationId: 41, animeIds: [1, 2] as [number, number] }];
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show', 2),
libraryItem(2, 'Show Season 1', 1),
]) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => ({
recommendations,
})) as typeof apiClient.getAnimeMergeRecommendations;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => root.render(<AnimeTab />));
await act(async () => findButton(container, 'Review merge').click());
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
recommendations = [];
await act(async () => window.dispatchEvent(new window.Event('focus')));
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
assert.match(container.textContent ?? '', /Show Season 1/);
await act(async () => root.unmount());
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
+55 -4
View File
@@ -10,6 +10,7 @@ import {
import { AnimeCard } from './AnimeCard';
import { AnimeDetailView } from './AnimeDetailView';
import { AnimeMergeDialog } from './AnimeMergeDialog';
import { DuplicateReviewStrip } from './DuplicateReviewStrip';
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
@@ -54,7 +55,17 @@ export function AnimeTab({
onNavigateToWord,
onOpenEpisodeDetail,
}: AnimeTabProps) {
const { anime, loading, error, reload } = useAnimeLibrary();
const {
anime,
loading,
error,
reload,
recommendations,
dismissRecommendation,
dismissingRecommendationId,
recommendationActionError,
clearRecommendation,
} = useAnimeLibrary();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
@@ -66,6 +77,8 @@ export function AnimeTab({
const [selectionMode, setSelectionMode] = useState(false);
const [checkedAnimeIds, setCheckedAnimeIds] = useState<number[]>([]);
const [showMergeDialog, setShowMergeDialog] = useState(false);
const [reviewRecommendationId, setReviewRecommendationId] = useState<number | null>(null);
const [reviewAnimeIds, setReviewAnimeIds] = useState<[number, number] | null>(null);
function toggleChecked(animeId: number): void {
setCheckedAnimeIds((ids) =>
@@ -105,6 +118,19 @@ export function AnimeTab({
const checkedEntries = checkedAnimeIds
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
const hydratedRecommendations = recommendations
.map((recommendation) => ({
...recommendation,
entries: recommendation.animeIds
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
.filter((entry): entry is (typeof anime)[number] => entry !== undefined),
}))
.filter((recommendation) => recommendation.entries.length >= 2);
const activeRecommendation = hydratedRecommendations[0] ?? null;
const reviewEntries = (reviewAnimeIds ?? [])
.map((animeId) => anime.find((entry) => entry.animeId === animeId))
.filter((entry): entry is (typeof anime)[number] => entry !== undefined);
const mergeEntries = reviewRecommendationId !== null ? reviewEntries : checkedEntries;
if (selectedAnimeId !== null) {
return (
@@ -180,6 +206,22 @@ export function AnimeTab({
</div>
</div>
{activeRecommendation ? (
<DuplicateReviewStrip
entries={activeRecommendation.entries}
current={1}
total={hydratedRecommendations.length}
dismissing={dismissingRecommendationId === activeRecommendation.recommendationId}
error={recommendationActionError}
onReview={() => {
setReviewRecommendationId(activeRecommendation.recommendationId);
setReviewAnimeIds(activeRecommendation.animeIds);
setShowMergeDialog(true);
}}
onDismiss={() => void dismissRecommendation(activeRecommendation.recommendationId)}
/>
) : null}
{selectionMode && (
<div className="flex items-center justify-between gap-3 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2">
<div className="text-xs text-ctp-overlay2">
@@ -216,12 +258,21 @@ export function AnimeTab({
</div>
)}
{showMergeDialog && checkedEntries.length >= 2 && (
{showMergeDialog && mergeEntries.length >= 2 && (
<AnimeMergeDialog
entries={checkedEntries}
onClose={() => setShowMergeDialog(false)}
entries={mergeEntries}
onClose={() => {
setShowMergeDialog(false);
setReviewRecommendationId(null);
setReviewAnimeIds(null);
}}
onMerged={() => {
if (reviewRecommendationId !== null) {
clearRecommendation(reviewRecommendationId);
}
exitSelectionMode();
setReviewRecommendationId(null);
setReviewAnimeIds(null);
reload();
}}
/>
@@ -0,0 +1,66 @@
import type { AnimeLibraryItem } from '../../types/stats';
interface DuplicateReviewStripProps {
entries: AnimeLibraryItem[];
current: number;
total: number;
dismissing: boolean;
error?: string | null;
onReview: () => void;
onDismiss: () => void;
}
export function DuplicateReviewStrip({
entries,
current,
total,
dismissing,
error = null,
onReview,
onDismiss,
}: DuplicateReviewStripProps) {
return (
<aside
aria-label="Possible duplicate library entries"
className="relative overflow-hidden rounded-lg border border-ctp-yellow/25 bg-ctp-yellow/[0.06] px-3 py-2.5"
>
<div className="absolute inset-y-0 left-0 w-0.5 bg-ctp-yellow/70" aria-hidden="true" />
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-ctp-yellow">Possible duplicate</span>
{total > 1 ? (
<span className="text-[10px] tabular-nums text-ctp-overlay1">
{current} of {total}
</span>
) : null}
</div>
<p className="mt-0.5 truncate text-xs text-ctp-subtext0">
{entries.map((entry) => entry.canonicalTitle).join(' · ')}
</p>
</div>
<button
type="button"
disabled={dismissing}
onClick={onDismiss}
className="shrink-0 rounded-md px-2.5 py-1.5 text-xs text-ctp-overlay2 transition-colors hover:bg-ctp-surface0 hover:text-ctp-text disabled:opacity-50"
>
{dismissing ? 'Dismissing…' : 'Not duplicates'}
</button>
<button
type="button"
disabled={dismissing}
onClick={onReview}
className="shrink-0 rounded-md border border-ctp-yellow/35 bg-ctp-yellow/10 px-2.5 py-1.5 text-xs font-medium text-ctp-yellow transition-colors hover:bg-ctp-yellow/20 disabled:opacity-50"
>
Review merge
</button>
</div>
{error ? (
<p role="alert" className="mt-1.5 text-xs text-ctp-red">
{error}
</p>
) : null}
</aside>
);
}
@@ -1,6 +1,7 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { apiClient } from '../../lib/api-client';
import { formatDuration } from '../../lib/formatters';
import { useModalFocus } from '../../hooks/useModalFocus';
import { AnimeCoverImage } from './AnimeCoverImage';
import type { AnimeLibraryItem } from '../../types/stats';
@@ -28,11 +29,12 @@ export function LibraryEntryPicker({
const [loadFailed, setLoadFailed] = useState(false);
const [query, setQuery] = useState(initialQuery);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const headingId = useId();
const searchId = useId();
const busy = busyAnimeId !== null;
useEffect(() => {
inputRef.current?.focus();
let cancelled = false;
apiClient
.getAnimeLibrary()
@@ -51,6 +53,17 @@ export function LibraryEntryPicker({
};
}, []);
useModalFocus({
dialogRef,
initialFocusRef: inputRef,
dismissDisabled: busy,
onDismiss: onClose,
});
const handleDismiss = () => {
if (!busy) onClose();
};
const excluded = useMemo(() => new Set(excludeAnimeIds), [excludeAnimeIds]);
const visible = useMemo(() => {
const term = query.trim().toLowerCase();
@@ -61,9 +74,13 @@ export function LibraryEntryPicker({
}, [entries, excluded, query]);
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
onClick={handleDismiss}
>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
@@ -77,9 +94,10 @@ export function LibraryEntryPicker({
</h3>
<button
type="button"
onClick={onClose}
onClick={handleDismiss}
disabled={busy}
aria-label="Close"
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
>
{'✕'}
</button>
@@ -119,7 +137,7 @@ export function LibraryEntryPicker({
<button
key={entry.animeId}
type="button"
disabled={busyAnimeId !== null}
disabled={busy}
onClick={() => onSelect(entry)}
className="w-full flex items-center gap-3 p-2.5 rounded-lg hover:bg-ctp-surface0 transition-colors text-left disabled:opacity-50"
>
+71 -4
View File
@@ -1,11 +1,16 @@
import { useCallback, useState, useEffect } from 'react';
import { getStatsClient } from './useStatsApi';
import type { AnimeLibraryItem } from '../types/stats';
import type { AnimeLibraryItem, StatsAnimeMergeRecommendation } from '../types/stats';
const BACKGROUND_REFRESH_MS = 30_000;
export function useAnimeLibrary() {
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [recommendations, setRecommendations] = useState<StatsAnimeMergeRecommendation[]>([]);
const [dismissingRecommendationId, setDismissingRecommendationId] = useState<number | null>(null);
const [recommendationActionError, setRecommendationActionError] = useState<string | null>(null);
const [reloadToken, setReloadToken] = useState(0);
const reload = useCallback(() => {
@@ -14,10 +19,14 @@ export function useAnimeLibrary() {
useEffect(() => {
let cancelled = false;
getStatsClient()
const client = getStatsClient();
client
.getAnimeLibrary()
.then((data) => {
if (!cancelled) setAnime(data);
if (!cancelled) {
setAnime(data);
setError(null);
}
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
@@ -25,10 +34,68 @@ export function useAnimeLibrary() {
.finally(() => {
if (!cancelled) setLoading(false);
});
// Recommendation support is deliberately non-blocking. An older backend
// should still be able to display its library even when this endpoint is
// unavailable.
client
.getAnimeMergeRecommendations()
.then((data) => {
if (!cancelled) {
setRecommendations(data.recommendations);
setRecommendationActionError(null);
}
})
.catch(() => {
// Preserve the last confirmed set. A transient polling failure should
// not make a pending review silently disappear.
});
return () => {
cancelled = true;
};
}, [reloadToken]);
return { anime, loading, error, reload };
useEffect(() => {
const refreshOnFocus = () => reload();
const interval = window.setInterval(reload, BACKGROUND_REFRESH_MS);
window.addEventListener('focus', refreshOnFocus);
return () => {
window.clearInterval(interval);
window.removeEventListener('focus', refreshOnFocus);
};
}, [reload]);
const dismissRecommendation = useCallback(async (recommendationId: number) => {
setDismissingRecommendationId(recommendationId);
setRecommendationActionError(null);
try {
await getStatsClient().dismissAnimeMergeRecommendation(recommendationId);
setRecommendations((current) =>
current.filter((item) => item.recommendationId !== recommendationId),
);
} catch {
setRecommendationActionError('Could not dismiss this suggestion. Try again.');
} finally {
setDismissingRecommendationId(null);
}
}, []);
const clearRecommendation = useCallback((recommendationId: number) => {
setRecommendations((current) =>
current.filter((item) => item.recommendationId !== recommendationId),
);
setRecommendationActionError(null);
}, []);
return {
anime,
loading,
error,
reload,
recommendations,
dismissRecommendation,
dismissingRecommendationId,
recommendationActionError,
clearRecommendation,
};
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef, type RefObject } from 'react';
const FOCUSABLE_SELECTOR = [
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'a[href]',
'[tabindex]:not([tabindex="-1"])',
].join(',');
interface UseModalFocusOptions {
dialogRef: RefObject<HTMLElement | null>;
initialFocusRef: RefObject<HTMLElement | null>;
dismissDisabled?: boolean;
onDismiss: () => void;
}
export function useModalFocus({
dialogRef,
initialFocusRef,
dismissDisabled = false,
onDismiss,
}: UseModalFocusOptions): void {
const dismissDisabledRef = useRef(dismissDisabled);
const onDismissRef = useRef(onDismiss);
dismissDisabledRef.current = dismissDisabled;
onDismissRef.current = onDismiss;
useEffect(() => {
const previouslyFocused =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
initialFocusRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (!dismissDisabledRef.current) {
event.preventDefault();
onDismissRef.current();
}
return;
}
if (event.key !== 'Tab') return;
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)];
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (
event.shiftKey &&
(document.activeElement === first || !dialog.contains(document.activeElement))
) {
event.preventDefault();
last.focus();
} else if (
!event.shiftKey &&
(document.activeElement === last || !dialog.contains(document.activeElement))
) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
previouslyFocused?.focus();
};
}, [dialogRef, initialFocusRef]);
}
+39
View File
@@ -44,6 +44,45 @@ test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
);
});
test('getAnimeMergeRecommendations loads pending duplicate pairs', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
globalThis.fetch = (async (input: RequestInfo | URL) => {
seenUrl = String(input);
return new Response(
JSON.stringify({ recommendations: [{ recommendationId: 4, animeIds: [7, 8] }] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof globalThis.fetch;
try {
const result = await apiClient.getAnimeMergeRecommendations();
assert.equal(seenUrl, `${BASE_URL}/api/stats/anime/merge-recommendations`);
assert.deepEqual(result.recommendations, [{ recommendationId: 4, animeIds: [7, 8] }]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('dismissAnimeMergeRecommendation sends DELETE for the selected suggestion', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
let seenMethod = '';
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
seenUrl = String(input);
seenMethod = init?.method ?? 'GET';
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}) as typeof globalThis.fetch;
try {
await apiClient.dismissAnimeMergeRecommendation(4);
assert.equal(seenUrl, `${BASE_URL}/api/stats/anime/merge-recommendations/4`);
assert.equal(seenMethod, 'DELETE');
} finally {
globalThis.fetch = originalFetch;
}
});
test('getCoverImages batches anime and media cover requests', async () => {
const originalFetch = globalThis.fetch;
let seenUrl = '';
+7
View File
@@ -129,6 +129,8 @@ export const apiClient = {
getMediaLibrary: () => fetchJson('mediaLibrary', '/api/stats/media'),
getMediaDetail: (videoId: number) => fetchJson('mediaDetail', `/api/stats/media/${videoId}`),
getAnimeLibrary: () => fetchJson('animeLibrary', '/api/stats/anime'),
getAnimeMergeRecommendations: () =>
fetchJson('animeMergeRecommendations', '/api/stats/anime/merge-recommendations'),
getAnimeDetail: (animeId: number) => fetchJson('animeDetail', `/api/stats/anime/${animeId}`),
getAnimeWords: (animeId: number, limit = 50) =>
fetchJson('animeWords', `/api/stats/anime/${animeId}/words?limit=${limit}`),
@@ -217,6 +219,11 @@ export const apiClient = {
});
return res.json() as Promise<StatsMoveVideoResponse>;
},
dismissAnimeMergeRecommendation: async (recommendationId: number): Promise<void> => {
await fetchResponse(`/api/stats/anime/merge-recommendations/${recommendationId}`, {
method: 'DELETE',
});
},
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
getAnimeKnownWordsSummary: (animeId: number) =>