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

- Add multi-select "Merge Selected" flow to fold duplicate library cards into one, preserving sessions, mined cards, and watch time
- Add per-episode "move to another entry" action for reassigning stray episodes, pruning the source entry when emptied
- Auto-merge library entries that resolve to the same AniList id when their parsed seasons are compatible
- Add mergeAnime/moveVideoToAnime service methods, stats-server routes, and HTTP contract types
This commit is contained in:
2026-08-10 22:52:34 -07:00
parent 2fefc83e3f
commit 5938095d92
19 changed files with 1629 additions and 9 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,131 @@
import { useState } from 'react';
import { apiClient } from '../../lib/api-client';
import { formatDuration, formatNumber } from '../../lib/formatters';
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 [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);
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);
}
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]" onClick={onClose}>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
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 className="text-sm font-semibold text-ctp-text">
Merge {entries.length} Library Entries
</h3>
<button
type="button"
onClick={onClose}
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
>
{'✕'}
</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}
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 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,163 @@
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();
}
});
+66 -1
View File
@@ -9,6 +9,7 @@ import {
} from '../../lib/library-card-size';
import { AnimeCard } from './AnimeCard';
import { AnimeDetailView } from './AnimeDetailView';
import { AnimeMergeDialog } from './AnimeMergeDialog';
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
@@ -62,6 +63,21 @@ 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);
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 +102,9 @@ 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);
if (selectedAnimeId !== null) {
return (
@@ -100,6 +119,7 @@ export function AnimeTab({
}
onAnimeDeleted={reload}
onAnilistRelinked={reload}
onEpisodeMoved={reload}
/>
);
}
@@ -143,11 +163,41 @@ 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>
{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 +206,26 @@ 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 && checkedEntries.length >= 2 && (
<AnimeMergeDialog
entries={checkedEntries}
onClose={() => setShowMergeDialog(false)}
onMerged={() => {
exitSelectionMode();
reload();
}}
/>
)}
</div>
);
}
+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 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 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,122 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { apiClient } from '../../lib/api-client';
import { formatDuration } from '../../lib/formatters';
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 [query, setQuery] = useState(initialQuery);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
let cancelled = false;
apiClient
.getAnimeLibrary()
.then((data) => {
if (!cancelled) setEntries(data);
})
.catch(() => {
if (!cancelled) setEntries([]);
});
return () => {
cancelled = true;
};
}, []);
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={onClose}>
<div className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" />
<div
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 className="text-sm font-semibold text-ctp-text">{heading}</h3>
<button
type="button"
onClick={onClose}
className="text-ctp-overlay2 hover:text-ctp-text text-lg leading-none"
>
{'✕'}
</button>
</div>
<input
ref={inputRef}
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 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>}
{entries !== null && visible.length === 0 && (
<div className="text-xs text-ctp-overlay2 p-3">No other titles</div>
)}
{visible.map((entry) => (
<button
key={entry.animeId}
type="button"
disabled={busyAnimeId !== null}
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>
);
}
+23
View File
@@ -9,6 +9,10 @@ import type {
StatsExcludedWordsRequest,
StatsHttpClient,
StatsJsonResponseMap,
StatsMergeAnimeRequest,
StatsMergeAnimeResponse,
StatsMoveVideoRequest,
StatsMoveVideoResponse,
StatsTrendGroupBy,
StatsTrendRange,
StatsVideoWatchedRequest,
@@ -194,6 +198,25 @@ 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>;
},
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
getAnimeKnownWordsSummary: (animeId: number) =>