feat(stats): add library entry deletion and app-wide delete progress (#174)

This commit is contained in:
2026-07-29 02:00:16 -07:00
committed by GitHub
parent 0d7084c8aa
commit 6d2a72e13b
40 changed files with 2013 additions and 125 deletions
+53 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
import { getStatsClient } from '../../hooks/useStatsApi';
import { confirmAnimeDelete } from '../../lib/delete-confirm';
import { epochDayToDate } from '../../lib/formatters';
import { AnimeHeader } from './AnimeHeader';
import { EpisodeList } from './EpisodeList';
@@ -16,6 +17,14 @@ interface AnimeDetailViewProps {
onBack: () => void;
onNavigateToWord?: (wordId: number) => void;
onOpenEpisodeDetail?: (videoId: number) => void;
/** Called after the whole library entry is deleted, so the caller can refresh. */
onAnimeDeleted?: () => void;
/**
* Called after the AniList link changes. The library list caches the old
* anilistId (and with it the cover URL), so it has to refetch or the grid
* keeps showing the previous title's art.
*/
onAnilistRelinked?: () => void;
}
type Range = 14 | 30 | 90;
@@ -139,10 +148,15 @@ export function AnimeDetailView({
onBack,
onNavigateToWord,
onOpenEpisodeDetail,
onAnimeDeleted,
onAnilistRelinked,
}: AnimeDetailViewProps) {
const { data, loading, error, reload } = useAnimeDetail(animeId);
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
const [coverRetryToken, setCoverRetryToken] = useState(0);
const [isDeletingAnime, setIsDeletingAnime] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const isDeletingAnimeRef = useRef(false);
const knownWordsSummary = useAnimeKnownWords(animeId);
useEffect(() => {
@@ -154,6 +168,40 @@ export function AnimeDetailView({
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
const { detail, episodes, anilistEntries } = data;
const handleDeleteAnime = async () => {
if (isDeletingAnimeRef.current) return;
isDeletingAnimeRef.current = true;
// Cleared up front so cancelling a retry doesn't leave the previous
// attempt's failure on screen.
setDeleteError(null);
let confirmed = false;
try {
confirmed = await confirmAnimeDelete(detail.canonicalTitle, detail.episodeCount);
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
isDeletingAnimeRef.current = false;
return;
}
if (!confirmed) {
isDeletingAnimeRef.current = false;
return;
}
setDeleteError(null);
setIsDeletingAnime(true);
try {
await getStatsClient().deleteAnime(animeId);
onAnimeDeleted?.();
onBack();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete this title.');
setIsDeletingAnime(false);
} finally {
isDeletingAnimeRef.current = false;
}
};
return (
<div className="space-y-4">
<button
@@ -168,7 +216,10 @@ export function AnimeDetailView({
anilistEntries={anilistEntries ?? []}
coverRetryToken={coverRetryToken}
onChangeAnilist={() => setShowAnilistSelector(true)}
onDeleteAnime={() => void handleDeleteAnime()}
isDeletingAnime={isDeletingAnime}
/>
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
<EpisodeList
episodes={episodes}
@@ -185,6 +236,7 @@ export function AnimeDetailView({
setShowAnilistSelector(false);
setCoverRetryToken((value) => value + 1);
reload();
onAnilistRelinked?.();
}}
/>
)}
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AnimeHeader } from './AnimeHeader';
import { confirmAnimeDelete, setDeleteConfirmPresenter } from '../../lib/delete-confirm';
import type { AnimeDetailData } from '../../types/stats';
const DETAIL: AnimeDetailData['detail'] = {
animeId: 3,
canonicalTitle: 'Project Radio Noise Season 2',
anilistId: 20661,
titleRomaji: 'Toaru Kagaku no Railgun S',
titleEnglish: 'A Certain Scientific Railgun S',
titleNative: null,
description: null,
totalSessions: 1,
totalActiveMs: 960_000,
totalCards: 0,
totalTokensSeen: 1_655,
totalLinesSeen: 300,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
episodeCount: 1,
lastWatchedMs: 1_700_000_000_000,
};
test('AnimeHeader offers a delete-entry action alongside the AniList actions', () => {
const markup = renderToStaticMarkup(
<AnimeHeader detail={DETAIL} anilistEntries={[]} onDeleteAnime={() => {}} />,
);
assert.match(markup, /Delete Entry/);
assert.match(markup, /Delete this title and every session and stat recorded for it/);
});
test('AnimeHeader hides the delete action when no handler is wired', () => {
const markup = renderToStaticMarkup(<AnimeHeader detail={DETAIL} anilistEntries={[]} />);
assert.doesNotMatch(markup, /Delete Entry/);
});
test('AnimeHeader shows a pending state while the entry is being deleted', () => {
const markup = renderToStaticMarkup(
<AnimeHeader detail={DETAIL} anilistEntries={[]} onDeleteAnime={() => {}} isDeletingAnime />,
);
assert.match(markup, /disabled=""/);
assert.match(markup, /animate-spin/);
assert.doesNotMatch(markup, /Delete Entry/);
});
test('confirmAnimeDelete spells out how much data the entry deletion removes', async () => {
const seen: string[] = [];
const restore = setDeleteConfirmPresenter((message) => {
seen.push(message);
return false;
});
try {
await confirmAnimeDelete('Railgun Season 2', 1);
await confirmAnimeDelete('Railgun Season 2', 3);
} finally {
restore();
}
assert.match(seen[0] ?? '', /"Railgun Season 2"/);
assert.match(seen[0] ?? '', /1 episode\b/);
assert.match(seen[1] ?? '', /3 episodes/);
assert.match(seen[0] ?? '', /every session and stat/);
});
@@ -6,6 +6,8 @@ interface AnimeHeaderProps {
anilistEntries: AnilistEntry[];
coverRetryToken?: number;
onChangeAnilist?: () => void;
onDeleteAnime?: () => void;
isDeletingAnime?: boolean;
}
function AnilistButton({ entry }: { entry: AnilistEntry }) {
@@ -32,6 +34,8 @@ export function AnimeHeader({
anilistEntries,
coverRetryToken = 0,
onChangeAnilist,
onDeleteAnime,
isDeletingAnime = false,
}: AnimeHeaderProps) {
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
(t): t is string => t != null && t !== detail.canonicalTitle,
@@ -95,6 +99,25 @@ export function AnimeHeader({
: 'Link to AniList'}
</button>
)}
{onDeleteAnime && (
<button
type="button"
onClick={onDeleteAnime}
disabled={isDeletingAnime}
title="Delete this title and every session and stat recorded for it"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-red/80 hover:bg-ctp-red/15 hover:text-ctp-red transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDeletingAnime ? (
<span
aria-hidden="true"
className="h-3 w-3 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
) : (
<span aria-hidden="true">{'\u2715'}</span>
)}
{isDeletingAnime ? 'Deleting\u2026' : 'Delete Entry'}
</button>
)}
</div>
{detail.description && (
<p className="text-xs text-ctp-subtext0 mt-3 line-clamp-3 leading-relaxed">
@@ -0,0 +1,179 @@
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 { AnimeDetailData, AnimeLibraryItem } 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(anilistId: number | null): AnimeLibraryItem {
return {
animeId: 7,
canonicalTitle: 'Test Anime Season 2',
anilistId,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
totalTokensSeen: 0,
episodeCount: 1,
episodesTotal: 13,
lastWatchedMs: 1,
};
}
function detailData(anilistId: number | null): AnimeDetailData {
return {
detail: {
animeId: 7,
canonicalTitle: 'Test Anime Season 2',
anilistId,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
description: null,
totalSessions: 1,
totalActiveMs: 1000,
totalCards: 0,
totalTokensSeen: 0,
totalLinesSeen: 0,
totalLookupCount: 0,
totalLookupHits: 0,
totalYomitanLookupCount: 0,
episodeCount: 1,
lastWatchedMs: 1,
},
episodes: [],
anilistEntries: [],
};
}
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;
}
test('AnimeTab refetches the library after the AniList entry is relinked', async () => {
const uninstallDom = installDom();
const original = {
getAnimeLibrary: apiClient.getAnimeLibrary,
getAnimeDetail: apiClient.getAnimeDetail,
getAnimeWords: apiClient.getAnimeWords,
getAnimeRollups: apiClient.getAnimeRollups,
getAnimeKnownWordsSummary: apiClient.getAnimeKnownWordsSummary,
searchAnilist: apiClient.searchAnilist,
reassignAnimeAnilist: apiClient.reassignAnimeAnilist,
};
// The library grid keys its cover URL off anilistId, so a stale list keeps
// pointing at the previous entry's art.
let anilistId: number | null = 14813;
let libraryFetches = 0;
apiClient.getAnimeLibrary = (async () => {
libraryFetches += 1;
return [libraryItem(anilistId)];
}) as typeof apiClient.getAnimeLibrary;
apiClient.getAnimeDetail = (async () => detailData(anilistId)) as typeof apiClient.getAnimeDetail;
apiClient.getAnimeWords = (async () => []) as typeof apiClient.getAnimeWords;
apiClient.getAnimeRollups = (async () => []) as typeof apiClient.getAnimeRollups;
apiClient.getAnimeKnownWordsSummary = (async () => ({
totalUniqueWords: 0,
knownWordCount: 0,
})) as typeof apiClient.getAnimeKnownWordsSummary;
apiClient.searchAnilist = (async () => [
{
id: 108489,
episodes: 12,
season: null,
seasonYear: null,
description: null,
coverImage: null,
title: { romaji: 'Test Anime Kan', english: null, native: null },
},
]) as typeof apiClient.searchAnilist;
apiClient.reassignAnimeAnilist = (async (_animeId: number, info: { anilistId: number }) => {
anilistId = info.anilistId;
}) as typeof apiClient.reassignAnimeAnilist;
try {
const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
await act(async () => {
root.render(<AnimeTab />);
});
assert.equal(libraryFetches, 1);
assert.match(
container.querySelector('img')?.getAttribute('src') ?? '',
/\/api\/stats\/anime\/7\/cover\?coverRetry=14813/,
);
await act(async () => {
findButton(container, 'Test Anime Season 2').click();
});
await act(async () => {
findButton(container, 'Change AniList Entry').click();
});
await act(async () => {
findButton(container, 'Test Anime Kan').click();
});
await act(async () => {
findButton(container, 'Back to Library').click();
});
assert.equal(libraryFetches, 2);
assert.match(
container.querySelector('img')?.getAttribute('src') ?? '',
/\/api\/stats\/anime\/7\/cover\?coverRetry=108489/,
);
await act(async () => {
root.unmount();
});
} finally {
Object.assign(apiClient, original);
uninstallDom();
}
});
+3 -1
View File
@@ -53,7 +53,7 @@ export function AnimeTab({
onNavigateToWord,
onOpenEpisodeDetail,
}: AnimeTabProps) {
const { anime, loading, error } = useAnimeLibrary();
const { anime, loading, error, reload } = useAnimeLibrary();
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
@@ -98,6 +98,8 @@ export function AnimeTab({
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
: undefined
}
onAnimeDeleted={reload}
onAnilistRelinked={reload}
/>
);
}
@@ -1,32 +1,49 @@
interface DeleteProgressToastProps {
/** Number of sessions currently being deleted. The toast is hidden when 0. */
count: number;
}
import { useSyncExternalStore } from 'react';
import { getDeleteProgressSnapshot, subscribeDeleteProgress } from '../../lib/delete-progress';
/**
* Fixed-position toast shown while session deletions are in flight.
* Global "deletion in progress" indicator.
*
* The per-row delete buttons are only visible on hover, so once the confirm
* dialog closes the user has no signal that a (potentially slow) batch delete
* is still running. This stays on screen, independent of hover, until the work
* finishes.
* Mounted once at the app root, outside every tab panel, so it shows no matter
* which view started the delete — per-tab copies were hidden along with their
* `hidden` tab panel, and detail views had no indicator at all. State comes
* from the delete-progress store rather than props so it also survives the
* initiating component unmounting mid-request.
*
* Renders two signals: a sweeping bar pinned to the top edge (visible even when
* the eye is on the content being deleted) and a bottom-right toast naming the
* work.
*/
export function DeleteProgressToast({ count }: DeleteProgressToastProps) {
export function DeleteProgressToast() {
const { count, label } = useSyncExternalStore(
subscribeDeleteProgress,
getDeleteProgressSnapshot,
getDeleteProgressSnapshot,
);
if (count <= 0) return null;
const message = count > 1 ? `Deleting ${count} items` : (label ?? 'Deleting');
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
>
<span
<>
<div
aria-hidden="true"
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
<span className="text-sm text-ctp-text">
Deleting {count} session{count === 1 ? '' : 's'}&hellip;
</span>
</div>
className="fixed inset-x-0 top-0 z-[2147483646] h-0.5 overflow-hidden bg-ctp-surface1"
>
<div className="h-full w-1/3 animate-indeterminate rounded-full bg-gradient-to-r from-ctp-red via-ctp-peach to-ctp-red" />
</div>
<div
role="status"
aria-live="polite"
className="fixed bottom-4 right-4 z-[2147483646] flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
>
<span
aria-hidden="true"
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
/>
<span className="text-sm text-ctp-text">{message}&hellip;</span>
</div>
</>
);
}
@@ -6,7 +6,6 @@ import { StreakCalendar } from './StreakCalendar';
import { RecentSessions } from './RecentSessions';
import { TrackingSnapshot } from './TrackingSnapshot';
import { TrendChart } from '../trends/TrendChart';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
import { apiClient } from '../../lib/api-client';
import { getStatsClient } from '../../hooks/useStatsApi';
@@ -160,8 +159,6 @@ export function OverviewTab({
deletingIds={deletingIds}
isActive={isActive}
/>
<DeleteProgressToast count={deletingIds.size} />
</div>
);
}
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
import { useSessions } from '../../hooks/useSessions';
import { SessionRow } from './SessionRow';
import { SessionDetail } from './SessionDetail';
import { DeleteProgressToast } from '../common/DeleteProgressToast';
import { apiClient } from '../../lib/api-client';
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
@@ -344,8 +343,6 @@ export function SessionsTab({
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
</div>
)}
<DeleteProgressToast count={deletingSessionIds.size} />
</div>
);
}
@@ -1,6 +1,7 @@
import { useRef, useState, useEffect } from 'react';
import { useWordDetail } from '../../hooks/useWordDetail';
import { apiClient } from '../../lib/api-client';
import { assetUrl } from '../../lib/asset-url';
import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters';
import {
buildStatsMineCardParams,
@@ -164,7 +165,10 @@ export function WordDetailPanel({
? data!.detail.headword
: occ.text.slice(0, 30);
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification('Anki Card Created', { body: `Mined: ${label}`, icon: '/favicon.png' });
new Notification('Anki Card Created', {
body: `Mined: ${label}`,
icon: assetUrl('favicon.png'),
});
} else if (typeof Notification !== 'undefined' && Notification.permission !== 'denied') {
Notification.requestPermission().then((p) => {
if (p === 'granted') new Notification('Anki Card Created', { body: `Mined: ${label}` });