feat(stats): add library entry merge and episode move (#190)

This commit is contained in:
2026-08-14 21:54:17 -07:00
committed by GitHub
parent c5a77ac067
commit 6fb3d2eb13
42 changed files with 4850 additions and 89 deletions
+26 -3
View File
@@ -5,22 +5,45 @@ import type { AnimeLibraryItem } from '../../types/stats';
interface AnimeCardProps {
anime: AnimeLibraryItem;
onClick: () => void;
/** While selecting, clicking the card toggles it instead of opening it. */
selectable?: boolean;
selected?: boolean;
}
export function AnimeCard({ anime, onClick }: AnimeCardProps) {
export function AnimeCard({
anime,
onClick,
selectable = false,
selected = false,
}: AnimeCardProps) {
return (
<button
type="button"
onClick={onClick}
className="group bg-ctp-surface0 border border-ctp-surface1 rounded-lg overflow-hidden hover:border-ctp-blue/50 hover:shadow-lg hover:shadow-ctp-blue/10 transition-all duration-200 hover:-translate-y-1 text-left w-full"
aria-pressed={selectable ? selected : undefined}
className={`group bg-ctp-surface0 border rounded-lg overflow-hidden hover:shadow-lg hover:shadow-ctp-blue/10 transition-all duration-200 hover:-translate-y-1 text-left w-full ${
selected ? 'border-ctp-blue' : 'border-ctp-surface1 hover:border-ctp-blue/50'
}`}
>
<div className="overflow-hidden">
<div className="overflow-hidden relative">
<AnimeCoverImage
animeId={anime.animeId}
title={anime.canonicalTitle}
coverRetryToken={anime.anilistId ?? 0}
className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105"
/>
{selectable && (
<span
aria-hidden="true"
className={`absolute top-2 left-2 w-5 h-5 rounded border flex items-center justify-center text-xs ${
selected
? 'bg-ctp-blue border-ctp-blue text-ctp-base'
: 'bg-ctp-crust/70 border-ctp-surface2 text-transparent'
}`}
>
{'✓'}
</span>
)}
</div>
<div className="p-3">
<div className="text-sm font-medium text-ctp-text truncate">{anime.canonicalTitle}</div>
@@ -25,6 +25,8 @@ interface AnimeDetailViewProps {
* keeps showing the previous title's art.
*/
onAnilistRelinked?: () => void;
/** Called after an episode is reassigned to another entry. */
onEpisodeMoved?: () => void;
}
type Range = 14 | 30 | 90;
@@ -150,6 +152,7 @@ export function AnimeDetailView({
onOpenEpisodeDetail,
onAnimeDeleted,
onAnilistRelinked,
onEpisodeMoved,
}: AnimeDetailViewProps) {
const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
@@ -223,6 +226,13 @@ export function AnimeDetailView({
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
<EpisodeList
episodes={episodes}
animeId={animeId}
onEpisodeMoved={(removedPreviousAnime) => {
onEpisodeMoved?.();
// The last episode taking the entry with it leaves nothing to show.
if (removedPreviousAnime) onBack();
else reload();
}}
onOpenDetail={onOpenEpisodeDetail ? (videoId) => onOpenEpisodeDetail(videoId) : undefined}
/>
<AnimeWatchChart animeId={animeId} />
@@ -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();
}
});
@@ -0,0 +1,164 @@
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';
interface AnimeMergeDialogProps {
entries: AnimeLibraryItem[];
onClose: () => void;
onMerged: (survivingAnimeId: number) => void;
}
/** Biggest entry first: the one most likely to carry the right title and art. */
function pickDefaultKeeper(entries: AnimeLibraryItem[]): number {
const best = [...entries].sort(
(a, b) => b.episodeCount - a.episodeCount || b.totalActiveMs - a.totalActiveMs,
)[0];
return best?.animeId ?? 0;
}
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);
const totalEpisodes = entries.reduce((sum, entry) => sum + entry.episodeCount, 0);
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)
.filter((animeId) => animeId !== keeperId);
if (sourceAnimeIds.length === 0) return;
setMerging(true);
setError(null);
try {
const result = await apiClient.mergeAnime(keeperId, sourceAnimeIds);
onMerged(result.animeId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to merge these entries.');
setMerging(false);
}
};
// Dismissing mid-request would leave the caller unaware of a merge that is
// still going to land, so the backdrop and close button are inert until it
// resolves.
const handleDismiss = () => {
if (!merging) onClose();
};
return (
<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}
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b border-ctp-surface1">
<div className="flex items-center justify-between">
<h3 id={headingId} className="text-sm font-semibold text-ctp-text">
Merge {entries.length} Library Entries
</h3>
<button
ref={closeButtonRef}
type="button"
onClick={handleDismiss}
disabled={merging}
aria-label="Close"
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
>
{'✕'}
</button>
</div>
<p className="text-xs text-ctp-overlay2 mt-2">
Pick the entry to keep. Every episode moves onto it and the others are removed; no
sessions or mined cards are deleted.
</p>
</div>
<div className="flex-1 overflow-y-auto p-2">
{entries.map((entry) => (
<button
key={entry.animeId}
type="button"
disabled={merging}
aria-pressed={keeperId === entry.animeId}
onClick={() => setKeeperId(entry.animeId)}
className={`w-full flex items-center gap-3 p-2.5 rounded-lg transition-colors text-left disabled:opacity-50 ${
keeperId === entry.animeId ? 'bg-ctp-surface1' : 'hover:bg-ctp-surface0'
}`}
>
<span
aria-hidden="true"
className={`w-4 h-4 rounded-full border shrink-0 ${
keeperId === entry.animeId
? 'border-ctp-blue bg-ctp-blue'
: 'border-ctp-surface2 bg-transparent'
}`}
/>
<AnimeCoverImage
animeId={entry.animeId}
title={entry.canonicalTitle}
coverRetryToken={entry.anilistId ?? 0}
className="w-10 h-14 rounded shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">{entry.canonicalTitle}</div>
<div className="text-xs text-ctp-overlay2 mt-0.5">
{entry.episodeCount} episode{entry.episodeCount !== 1 ? 's' : ''} ·{' '}
{formatDuration(entry.totalActiveMs)} · {formatNumber(entry.totalCards)} cards
</div>
</div>
{keeperId === entry.animeId ? (
<span className="text-xs text-ctp-blue shrink-0">Keep</span>
) : null}
</button>
))}
</div>
<div className="p-4 border-t border-ctp-surface1 space-y-2">
{error ? (
<div role="alert" className="text-xs text-ctp-red">
{error}
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-ctp-overlay2">
Result: {totalEpisodes} episode{totalEpisodes !== 1 ? 's' : ''} ·{' '}
{formatDuration(totalActiveMs)} · {formatNumber(totalCards)} cards
</div>
<button
type="button"
disabled={merging}
onClick={() => void handleMerge()}
className="px-3 py-1.5 rounded-lg bg-ctp-blue/15 border border-ctp-blue/40 text-xs text-ctp-blue hover:bg-ctp-blue/25 transition-colors disabled:opacity-50"
>
{merging ? 'Merging…' : 'Merge Entries'}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,504 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { AnimeLibraryItem, StatsMergeAnimeResponse } from '../../types/stats';
import { AnimeTab } from './AnimeTab';
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, episodeCount: number): AnimeLibraryItem {
return {
animeId,
canonicalTitle: title,
anilistId: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 1,
totalTokensSeen: 0,
episodeCount,
episodesTotal: null,
lastWatchedMs: animeId,
};
}
function findButton(container: Element, label: string): HTMLElement {
const match = [...container.querySelectorAll('button')].find((button) =>
(button.textContent ?? '').includes(label),
);
assert.ok(match, `expected a "${label}" button`);
return match as unknown as HTMLElement;
}
/** Library cards only expose aria-pressed while selection mode is on. */
function cardButtons(container: Element): HTMLButtonElement[] {
return [...container.querySelectorAll('button[aria-pressed]')] as unknown as HTMLButtonElement[];
}
function mergeButton(container: Element): HTMLButtonElement {
const match = [...container.querySelectorAll('button')].find(
(button) => (button.textContent ?? '').trim() === 'Merge Selected',
);
assert.ok(match, 'expected a "Merge Selected" button');
return match as unknown as HTMLButtonElement;
}
test('AnimeTab merges the selected duplicate entries into the chosen keeper', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
mergeAnime: apiClient.mergeAnime,
};
// Two cards for one show, the split this feature exists to undo.
let entries = [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
let libraryFetches = 0;
let mergeCall: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
apiClient.getAnimeLibrary = (async () => {
libraryFetches += 1;
return entries;
}) as typeof apiClient.getAnimeLibrary;
apiClient.mergeAnime = (async (targetAnimeId: number, sourceAnimeIds: number[]) => {
mergeCall = { targetAnimeId, sourceAnimeIds };
entries = [libraryItem(1, 'Show', 3)];
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.equal(libraryFetches, 1);
await act(async () => {
findButton(container, 'Select').click();
});
// Nothing to merge until at least two entries are picked.
assert.equal(mergeButton(container).disabled, true);
// Sorted by last watched, so the season-tagged duplicate comes first.
const cards = cardButtons(container);
assert.equal(cards.length, 2);
assert.match(cards[0]?.textContent ?? '', /Show Season 1/);
await act(async () => {
cards[0]?.click();
});
assert.equal(mergeButton(container).disabled, true);
await act(async () => {
cardButtons(container)[1]?.click();
});
assert.equal(mergeButton(container).disabled, false);
await act(async () => {
mergeButton(container).click();
});
// The dialog defaults to the entry with the most episodes.
assert.match(container.textContent ?? '', /Merge 2 Library Entries/);
await act(async () => {
findButton(container, 'Merge Entries').click();
});
assert.deepEqual(mergeCall, { targetAnimeId: 1, sourceAnimeIds: [2] });
assert.equal(libraryFetches, 2);
// Selection mode closes and the grid is back to a single card.
assert.doesNotMatch(container.textContent ?? '', /Merge 2 Library Entries/);
assert.doesNotMatch(container.textContent ?? '', /Show Season 1/);
await act(async () => {
root.unmount();
});
} finally {
Object.assign(apiClient, original);
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();
}
});
test('AnimeTab keeps the library visible through a transient background refresh failure', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
};
let failLibrary = false;
apiClient.getAnimeLibrary = (async () => {
if (failLibrary) throw new Error('temporary failure');
return [libraryItem(1, 'Show', 2), libraryItem(2, 'Show Season 1', 1)];
}) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = (async () => ({
recommendations: [],
})) as typeof apiClient.getAnimeMergeRecommendations;
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => root.render(<AnimeTab />));
assert.match(container.textContent ?? '', /Show Season 1/);
failLibrary = true;
await act(async () => window.dispatchEvent(new window.Event('focus')));
// A failed poll keeps the last confirmed library instead of replacing the
// whole tab (and any open dialog) with the error screen.
assert.match(container.textContent ?? '', /Show Season 1/);
assert.doesNotMatch(container.textContent ?? '', /Error:/);
} finally {
await act(async () => root.unmount());
Object.assign(apiClient, original);
uninstallDom();
}
});
test('AnimeTab does not resurrect a dismissed recommendation from a stale in-flight refresh', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeMergeRecommendations: apiClient.getAnimeMergeRecommendations,
dismissAnimeMergeRecommendation: apiClient.dismissAnimeMergeRecommendation,
};
const payload = {
recommendations: [{ recommendationId: 41, animeIds: [1, 2] as [number, number] }],
};
let holdNextFetch = false;
let releaseHeldFetch: (() => void) | null = null;
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Show', 2),
libraryItem(2, 'Show Season 1', 1),
]) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeMergeRecommendations = ((): Promise<typeof payload> => {
if (!holdNextFetch) return Promise.resolve(payload);
holdNextFetch = false;
return new Promise((resolve) => {
releaseHeldFetch = () => resolve(payload);
});
}) as typeof apiClient.getAnimeMergeRecommendations;
apiClient.dismissAnimeMergeRecommendation =
(async () => {}) as typeof apiClient.dismissAnimeMergeRecommendation;
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
try {
await act(async () => root.render(<AnimeTab />));
assert.match(container.textContent ?? '', /Possible duplicate/);
// Focus starts a refresh whose response is still in flight when the user
// dismisses; the stale payload must not bring the strip back.
holdNextFetch = true;
await act(async () => window.dispatchEvent(new window.Event('focus')));
await act(async () => findButton(container, 'Not duplicates').click());
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
await act(async () => {
releaseHeldFetch?.();
});
assert.doesNotMatch(container.textContent ?? '', /Possible duplicate/);
} finally {
await act(async () => root.unmount());
Object.assign(apiClient, original);
uninstallDom();
}
});
+118 -2
View File
@@ -9,6 +9,8 @@ import {
} from '../../lib/library-card-size';
import { AnimeCard } from './AnimeCard';
import { AnimeDetailView } from './AnimeDetailView';
import { AnimeMergeDialog } from './AnimeMergeDialog';
import { DuplicateReviewStrip } from './DuplicateReviewStrip';
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
@@ -53,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>(() =>
@@ -62,6 +74,23 @@ export function AnimeTab({
),
);
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
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) =>
ids.includes(animeId) ? ids.filter((id) => id !== animeId) : [...ids, animeId],
);
}
function exitSelectionMode(): void {
setSelectionMode(false);
setCheckedAnimeIds([]);
setShowMergeDialog(false);
}
function handleCardSizeChange(size: LibraryCardSize): void {
setCardSize(size);
@@ -86,6 +115,22 @@ export function AnimeTab({
}, [anime, search, sortKey]);
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
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 (
@@ -100,6 +145,7 @@ export function AnimeTab({
}
onAnimeDeleted={reload}
onAnilistRelinked={reload}
onEpisodeMoved={reload}
/>
);
}
@@ -143,11 +189,57 @@ export function AnimeTab({
</button>
))}
</div>
<button
type="button"
onClick={() => (selectionMode ? exitSelectionMode() : setSelectionMode(true))}
title="Select several entries to merge them into one"
className={`px-2 py-2 rounded-lg border text-xs shrink-0 transition-colors ${
selectionMode
? 'bg-ctp-blue/15 border-ctp-blue/40 text-ctp-blue'
: 'bg-ctp-surface0 border-ctp-surface1 text-ctp-overlay2 hover:text-ctp-subtext0'
}`}
>
{selectionMode ? 'Cancel' : 'Select'}
</button>
<div className="text-xs text-ctp-overlay2 shrink-0">
{filtered.length} titles · {formatDuration(totalMs)}
</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">
{checkedEntries.length === 0
? 'Pick the duplicate entries to combine'
: `${checkedEntries.length} selected`}
</div>
<button
type="button"
disabled={checkedEntries.length < 2}
onClick={() => setShowMergeDialog(true)}
className="px-3 py-1.5 rounded-lg bg-ctp-blue/15 border border-ctp-blue/40 text-xs text-ctp-blue hover:bg-ctp-blue/25 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Merge Selected
</button>
</div>
)}
{filtered.length === 0 ? (
<div className="text-sm text-ctp-overlay2 p-4">No titles found</div>
) : (
@@ -156,11 +248,35 @@ export function AnimeTab({
<AnimeCard
key={item.animeId}
anime={item}
onClick={() => setSelectedAnimeId(item.animeId)}
selectable={selectionMode}
selected={checkedAnimeIds.includes(item.animeId)}
onClick={() =>
selectionMode ? toggleChecked(item.animeId) : setSelectedAnimeId(item.animeId)
}
/>
))}
</div>
)}
{showMergeDialog && mergeEntries.length >= 2 && (
<AnimeMergeDialog
entries={mergeEntries}
onClose={() => {
setShowMergeDialog(false);
setReviewRecommendationId(null);
setReviewAnimeIds(null);
}}
onMerged={() => {
if (reviewRecommendationId !== null) {
clearRecommendation(reviewRecommendationId);
}
exitSelectionMode();
setReviewRecommendationId(null);
setReviewAnimeIds(null);
reload();
}}
/>
)}
</div>
);
}
@@ -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>
);
}
+62 -1
View File
@@ -4,21 +4,39 @@ import { apiClient } from '../../lib/api-client';
import { confirmEpisodeDelete } from '../../lib/delete-confirm';
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
import { EpisodeDetail } from './EpisodeDetail';
import { LibraryEntryPicker } from './LibraryEntryPicker';
import type { AnimeEpisode } from '../../types/stats';
/**
* Row actions that only appear on hover. Keyboard focus and pointers with no
* hover (touch) reveal them too, otherwise those users cannot reach the button
* at all.
*/
const HOVER_REVEALED =
'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100';
interface EpisodeListProps {
episodes: AnimeEpisode[];
/** Entry these episodes currently belong to; excluded from the move picker. */
animeId?: number;
onEpisodeDeleted?: () => void;
/** Fires after an episode is reassigned, so the caller can refetch. */
onEpisodeMoved?: (removedPreviousAnime: boolean) => void;
onOpenDetail?: (videoId: number) => void;
}
export function EpisodeList({
episodes: initialEpisodes,
animeId,
onEpisodeDeleted,
onEpisodeMoved,
onOpenDetail,
}: EpisodeListProps) {
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
const [episodes, setEpisodes] = useState(initialEpisodes);
const [movingEpisode, setMovingEpisode] = useState<AnimeEpisode | null>(null);
const [moveTargetId, setMoveTargetId] = useState<number | null>(null);
const [moveError, setMoveError] = useState<string | null>(null);
if (episodes.length === 0) return null;
@@ -51,6 +69,22 @@ export function EpisodeList({
onEpisodeDeleted?.();
};
const handleMoveEpisode = async (videoId: number, targetAnimeId: number) => {
setMoveTargetId(targetAnimeId);
setMoveError(null);
try {
const result = await apiClient.moveVideoToAnime(videoId, targetAnimeId);
setEpisodes((prev) => prev.filter((ep) => ep.videoId !== videoId));
if (expandedVideoId === videoId) setExpandedVideoId(null);
setMovingEpisode(null);
onEpisodeMoved?.(result.removedPreviousAnime);
} catch (err) {
setMoveError(err instanceof Error ? err.message : 'Failed to move this episode.');
} finally {
setMoveTargetId(null);
}
};
const watchedCount = episodes.filter((ep) => ep.watched).length;
return (
@@ -164,14 +198,28 @@ export function EpisodeList({
>
{'\u2713'}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setMoveError(null);
setMovingEpisode(ep);
}}
className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue focus-visible:text-ctp-blue hover:bg-ctp-blue/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`}
title="Move to another library entry"
aria-label="Move to another library entry"
>
{'\u2192'}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void handleDeleteEpisode(ep.videoId, ep.canonicalTitle);
}}
className="w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 text-xs flex items-center justify-center"
className={`w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red focus-visible:text-ctp-red hover:bg-ctp-red/10 transition-colors text-xs flex items-center justify-center ${HOVER_REVEALED}`}
title="Delete episode"
aria-label="Delete episode"
>
{'\u2715'}
</button>
@@ -191,6 +239,19 @@ export function EpisodeList({
</tbody>
</table>
</div>
{movingEpisode && (
<LibraryEntryPicker
heading={`Move "${movingEpisode.canonicalTitle}" To`}
excludeAnimeIds={animeId != null ? [animeId] : []}
busyAnimeId={moveTargetId}
error={moveError}
onSelect={(entry) => void handleMoveEpisode(movingEpisode.videoId, entry.animeId)}
onClose={() => {
setMovingEpisode(null);
setMoveError(null);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Window } from 'happy-dom';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { apiClient } from '../../lib/api-client';
import type { AnimeEpisode, AnimeLibraryItem, StatsMoveVideoResponse } from '../../types/stats';
import { EpisodeList } from './EpisodeList';
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 episode(videoId: number, title: string): AnimeEpisode {
return {
videoId,
episode: videoId,
season: null,
durationMs: 1_440_000,
endedMediaMs: null,
watched: 0,
canonicalTitle: title,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
totalTokensSeen: 0,
totalYomitanLookupCount: 0,
lastWatchedMs: 1,
};
}
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,
};
}
function findButtonByTitle(container: Element, title: string): HTMLElement {
const match = [...container.querySelectorAll('button')].find(
(button) => button.getAttribute('title') === title,
);
assert.ok(match, `expected a button titled "${title}"`);
return match as unknown as HTMLElement;
}
function findButtonByText(container: Element, text: string): HTMLElement {
const match = [...container.querySelectorAll('button')].find((button) =>
(button.textContent ?? '').includes(text),
);
assert.ok(match, `expected a "${text}" button`);
return match as unknown as HTMLElement;
}
test('EpisodeList moves an episode to the library entry picked in the dialog', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
moveVideoToAnime: apiClient.moveVideoToAnime,
};
let moveCall: { videoId: number; animeId: number } | null = null;
let movedResult: boolean | null = null;
apiClient.getAnimeLibrary = (async () => [
libraryItem(1, 'Current Entry'),
libraryItem(2, 'Real Series'),
]) as typeof apiClient.getAnimeLibrary;
apiClient.moveVideoToAnime = (async (videoId: number, animeId: number) => {
moveCall = { videoId, animeId };
return {
ok: true,
animeId,
previousAnimeId: 1,
removedPreviousAnime: true,
} satisfies StatsMoveVideoResponse;
}) as typeof apiClient.moveVideoToAnime;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(
<EpisodeList
episodes={[episode(5, 'Stray Episode')]}
animeId={1}
onEpisodeMoved={(removedPreviousAnime) => {
movedResult = removedPreviousAnime;
}}
/>,
);
});
await act(async () => {
findButtonByTitle(container, 'Move to another library entry').click();
});
assert.match(container.textContent ?? '', /Move "Stray Episode" To/);
// The entry the episode already belongs to is not offered as a target.
assert.doesNotMatch(container.textContent ?? '', /Current Entry/);
await act(async () => {
findButtonByText(container, 'Real Series').click();
});
assert.deepEqual(moveCall, { videoId: 5, animeId: 2 });
assert.equal(movedResult, true);
// The row leaves this entry's list and the picker closes.
assert.doesNotMatch(container.textContent ?? '', /Stray Episode/);
await act(async () => {
root.unmount();
});
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
@@ -0,0 +1,168 @@
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';
interface LibraryEntryPickerProps {
heading: string;
/** Entries that cannot be picked, typically the one being moved away from. */
excludeAnimeIds?: number[];
initialQuery?: string;
busyAnimeId?: number | null;
error?: string | null;
onSelect: (entry: AnimeLibraryItem) => void;
onClose: () => void;
}
export function LibraryEntryPicker({
heading,
excludeAnimeIds = [],
initialQuery = '',
busyAnimeId = null,
error = null,
onSelect,
onClose,
}: LibraryEntryPickerProps) {
const [entries, setEntries] = useState<AnimeLibraryItem[] | null>(null);
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(() => {
let cancelled = false;
apiClient
.getAnimeLibrary()
.then((data) => {
if (!cancelled) setEntries(data);
})
.catch(() => {
// Distinct from an empty library: telling the user "no other titles"
// when the request failed hides a retryable error.
if (cancelled) return;
setEntries([]);
setLoadFailed(true);
});
return () => {
cancelled = true;
};
}, []);
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();
return (entries ?? [])
.filter((entry) => !excluded.has(entry.animeId))
.filter((entry) => !term || entry.canonicalTitle.toLowerCase().includes(term))
.sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
}, [entries, excluded, query]);
return (
<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}
className="relative bg-ctp-base border border-ctp-surface1 rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] flex flex-col animate-fade-in"
onClick={(e) => e.stopPropagation()}
>
<div className="p-4 border-b border-ctp-surface1">
<div className="flex items-center justify-between mb-3">
<h3 id={headingId} className="text-sm font-semibold text-ctp-text">
{heading}
</h3>
<button
type="button"
onClick={handleDismiss}
disabled={busy}
aria-label="Close"
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none disabled:opacity-50"
>
{'✕'}
</button>
</div>
<label htmlFor={searchId} className="sr-only">
Search library
</label>
<input
ref={inputRef}
id={searchId}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search library..."
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
{error ? (
<div role="alert" className="text-xs text-ctp-red mt-2">
{error}
</div>
) : null}
</div>
<div className="flex-1 overflow-y-auto p-2">
{entries === null && <div className="text-xs text-ctp-overlay2 p-3">Loading...</div>}
{loadFailed && (
<div role="alert" className="text-xs text-ctp-red p-3">
Could not load the library. Close this dialog and try again.
</div>
)}
{!loadFailed && entries !== null && visible.length === 0 && (
<div className="text-xs text-ctp-overlay2 p-3">
{query.trim() ? 'No matches' : 'No other titles'}
</div>
)}
{visible.map((entry) => (
<button
key={entry.animeId}
type="button"
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"
>
<AnimeCoverImage
animeId={entry.animeId}
title={entry.canonicalTitle}
coverRetryToken={entry.anilistId ?? 0}
className="w-10 h-14 rounded shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="text-sm text-ctp-text truncate">{entry.canonicalTitle}</div>
<div className="text-xs text-ctp-overlay2 mt-0.5">
{entry.episodeCount} episode{entry.episodeCount !== 1 ? 's' : ''} ·{' '}
{formatDuration(entry.totalActiveMs)}
</div>
</div>
{busyAnimeId === entry.animeId ? (
<span className="text-xs text-ctp-blue shrink-0">Moving...</span>
) : (
<span className="text-xs text-ctp-overlay2 shrink-0">Select</span>
)}
</button>
))}
</div>
</div>
</div>
);
}
+88 -6
View File
@@ -1,12 +1,25 @@
import { useCallback, useState, useEffect } from 'react';
import { useCallback, useState, useEffect, useRef } 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);
// Once the library has rendered, a failed background poll must not replace
// it with the error screen (and unmount any open dialog with it).
const hasLoadedRef = useRef(false);
// Ids dismissed or merged away locally. A recommendations response that was
// already in flight when the user acted would otherwise resurrect them.
// Dismissed and merged rows never return to pending server-side, so the set
// only ever suppresses genuinely stale data.
const removedRecommendationIdsRef = useRef<Set<number>>(new Set());
const reload = useCallback(() => {
setReloadToken((token) => token + 1);
@@ -14,21 +27,90 @@ export function useAnimeLibrary() {
useEffect(() => {
let cancelled = false;
getStatsClient()
const client = getStatsClient();
client
.getAnimeLibrary()
.then((data) => {
if (!cancelled) setAnime(data);
if (!cancelled) {
hasLoadedRef.current = true;
setAnime(data);
setError(null);
}
})
.catch((err: Error) => {
if (!cancelled) setError(err.message);
if (!cancelled && !hasLoadedRef.current) setError(err.message);
})
.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.filter(
(item) => !removedRecommendationIdsRef.current.has(item.recommendationId),
),
);
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);
removedRecommendationIdsRef.current.add(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) => {
removedRecommendationIdsRef.current.add(recommendationId);
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 = '';
+30
View File
@@ -11,6 +11,10 @@ import type {
StatsExcludedWordsRequest,
StatsHttpClient,
StatsJsonResponseMap,
StatsMergeAnimeRequest,
StatsMergeAnimeResponse,
StatsMoveVideoRequest,
StatsMoveVideoResponse,
StatsTrendGroupBy,
StatsTrendRange,
StatsVideoWatchedRequest,
@@ -140,6 +144,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}`),
@@ -209,6 +215,30 @@ export const apiClient = {
fetchResponse(`/api/stats/anime/${animeId}`, { method: 'DELETE' }),
);
},
mergeAnime: async (
targetAnimeId: number,
sourceAnimeIds: number[],
): Promise<StatsMergeAnimeResponse> => {
const res = await fetchResponse(`/api/stats/anime/${targetAnimeId}/merge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceAnimeIds } satisfies StatsMergeAnimeRequest),
});
return res.json() as Promise<StatsMergeAnimeResponse>;
},
moveVideoToAnime: async (videoId: number, animeId: number): Promise<StatsMoveVideoResponse> => {
const res = await fetchResponse(`/api/stats/media/${videoId}/anime`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ animeId } satisfies StatsMoveVideoRequest),
});
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) =>