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
+20 -7
View File
@@ -15,6 +15,7 @@ import type {
} from '../types/stats';
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
import { appendCoverRetryToken } from './cover-retry';
import { trackDelete } from './delete-progress';
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
@@ -169,17 +170,29 @@ export const apiClient = {
});
},
deleteSession: async (sessionId: number): Promise<void> => {
await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
await trackDelete('Deleting session', () =>
fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' }),
);
},
deleteSessions: async (sessionIds: number[]): Promise<void> => {
await fetchResponse('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
});
const label = `Deleting ${sessionIds.length} session${sessionIds.length === 1 ? '' : 's'}`;
await trackDelete(label, () =>
fetchResponse('/api/stats/sessions', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
}),
);
},
deleteVideo: async (videoId: number): Promise<void> => {
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
await trackDelete('Deleting episode', () =>
fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' }),
);
},
deleteAnime: async (animeId: number): Promise<void> => {
await trackDelete('Deleting library entry', () =>
fetchResponse(`/api/stats/anime/${animeId}`, { method: 'DELETE' }),
);
},
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { assetUrl, resolveAssetUrl } from './asset-url';
// vite.config.ts sets `base: './'`, so this is what the built bundle sees.
const BUILT_BASE = './';
test('built asset URLs are never root-absolute', () => {
assert.equal(resolveAssetUrl('favicon.png', BUILT_BASE).startsWith('/'), false);
});
test('built asset URL resolves next to a file:// index.html', () => {
const resolved = new URL(
resolveAssetUrl('favicon.png', BUILT_BASE),
'file:///opt/SubMiner/stats/dist/index.html',
);
assert.equal(resolved.href, 'file:///opt/SubMiner/stats/dist/favicon.png');
});
test('built asset URL resolves against the server root when served over http', () => {
const resolved = new URL(resolveAssetUrl('favicon.png', BUILT_BASE), 'http://127.0.0.1:8770/');
assert.equal(resolved.href, 'http://127.0.0.1:8770/favicon.png');
});
test('dev server base stays root-absolute', () => {
assert.equal(resolveAssetUrl('favicon.png', '/'), '/favicon.png');
});
test('a base without a trailing slash still joins cleanly', () => {
assert.equal(resolveAssetUrl('favicon.png', '/stats'), '/stats/favicon.png');
});
test('a leading slash in the requested path is tolerated', () => {
assert.equal(resolveAssetUrl('/favicon.png', BUILT_BASE), './favicon.png');
});
test('assetUrl falls back to a relative base outside a Vite bundle', () => {
assert.equal(assetUrl('favicon.png').startsWith('/'), false);
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Resolve a bundled public asset against Vite's base URL.
*
* The in-player stats window is loaded with `loadFile`, so the document lives on
* `file://`. A root-absolute path like `/favicon.png` resolves to the filesystem
* root there and 404s, while the HTTP-served web app resolves it fine. Vite
* rewrites asset refs in `index.html` but not string literals in JSX, so build
* the URL from the configured base instead of hardcoding a leading slash.
*/
export function resolveAssetUrl(path: string, base: string): string {
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
return `${normalizedBase}${path.replace(/^\/+/, '')}`;
}
function currentBase(): string {
// Vite injects BASE_URL at build time ('./' per vite.config.ts) and serves '/'
// in dev. Outside a Vite bundle (tests) there is no env, so fall back to './'.
const env = (import.meta as { env?: Record<string, string | undefined> }).env;
return env?.BASE_URL || './';
}
export function assetUrl(path: string): string {
return resolveAssetUrl(path, currentBase());
}
+7
View File
@@ -60,6 +60,13 @@ export function confirmAnimeGroupDelete(title: string, count: number): Promise<b
);
}
export function confirmAnimeDelete(title: string, episodeCount: number): Promise<boolean> {
const episodes = `${episodeCount} episode${episodeCount === 1 ? '' : 's'}`;
return confirmWithStatsNativeDialogLayer(
`Delete "${title}" from your library? This removes ${episodes} plus every session and stat recorded for them.`,
);
}
export function confirmEpisodeDelete(title: string): Promise<boolean> {
return confirmWithStatsNativeDialogLayer(`Delete "${title}" and all its sessions?`);
}
+127
View File
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { renderToStaticMarkup } from 'react-dom/server';
import { DeleteProgressToast } from '../components/common/DeleteProgressToast';
import { apiClient } from './api-client';
import {
beginDeleteTask,
getDeleteProgressSnapshot,
resetDeleteProgress,
subscribeDeleteProgress,
trackDelete,
} from './delete-progress';
test('delete progress store reports the oldest label and notifies subscribers', () => {
resetDeleteProgress();
let notifications = 0;
const unsubscribe = subscribeDeleteProgress(() => {
notifications += 1;
});
try {
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
const endFirst = beginDeleteTask('Deleting session');
const endSecond = beginDeleteTask('Deleting episode');
assert.deepEqual(getDeleteProgressSnapshot(), { count: 2, label: 'Deleting session' });
assert.equal(notifications, 2);
endFirst();
assert.deepEqual(getDeleteProgressSnapshot(), { count: 1, label: 'Deleting episode' });
endFirst();
assert.deepEqual(
getDeleteProgressSnapshot(),
{ count: 1, label: 'Deleting episode' },
'ending the same task twice must not drop another task',
);
endSecond();
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
} finally {
unsubscribe();
resetDeleteProgress();
}
});
test('trackDelete clears the indicator even when the request rejects', async () => {
resetDeleteProgress();
await assert.rejects(
trackDelete('Deleting library entry', async () => {
assert.equal(getDeleteProgressSnapshot().count, 1);
throw new Error('boom');
}),
/boom/,
);
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
});
test('DeleteProgressToast stays hidden while idle and reports active deletes', () => {
resetDeleteProgress();
assert.equal(renderToStaticMarkup(<DeleteProgressToast />), '');
const end = beginDeleteTask('Deleting library entry');
try {
const markup = renderToStaticMarkup(<DeleteProgressToast />);
assert.match(markup, /role="status"/);
assert.match(markup, /Deleting library entry/);
assert.match(markup, /animate-indeterminate/);
} finally {
end();
}
const endFirst = beginDeleteTask('Deleting session');
const endSecond = beginDeleteTask('Deleting episode');
try {
assert.match(renderToStaticMarkup(<DeleteProgressToast />), /Deleting 2 items/);
} finally {
endFirst();
endSecond();
resetDeleteProgress();
}
});
test('the delete indicator is mounted once at the app root, not inside tab panels', () => {
const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const read = (relativePath: string): string =>
fs.readFileSync(path.join(srcDir, relativePath), 'utf8');
const app = read('App.tsx');
assert.match(app, /<DeleteProgressToast \/>/);
// Sibling of the confirm dialog: outside every `hidden` tab panel, so the
// indicator survives tab switches and detail-view navigation.
assert.match(app, /<DeleteConfirmDialog \/>\s*<DeleteProgressToast \/>/);
for (const tab of [
'components/overview/OverviewTab.tsx',
'components/sessions/SessionsTab.tsx',
]) {
assert.doesNotMatch(read(tab), /DeleteProgressToast/, `${tab} must not mount its own toast`);
}
});
test('every api client delete registers with the global progress indicator', async () => {
const originalFetch = globalThis.fetch;
const seenCounts: number[] = [];
globalThis.fetch = (async () => {
seenCounts.push(getDeleteProgressSnapshot().count);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}) as typeof globalThis.fetch;
try {
resetDeleteProgress();
await apiClient.deleteSession(1);
await apiClient.deleteSessions([1, 2]);
await apiClient.deleteVideo(3);
await apiClient.deleteAnime(4);
assert.deepEqual(seenCounts, [1, 1, 1, 1]);
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
} finally {
globalThis.fetch = originalFetch;
resetDeleteProgress();
}
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Tiny external store tracking in-flight delete requests.
*
* Every delete goes through the API client, which registers here, so a single
* globally mounted indicator can report progress no matter which tab, detail
* view or overlay window started the work. Keeping the state outside React
* means the indicator survives the deleting component unmounting mid-request
* (navigating back after deleting a library entry, for example).
*/
export interface DeleteProgressSnapshot {
/** Number of delete requests currently in flight. */
count: number;
/** Label for the oldest in-flight request, or null when idle. */
label: string | null;
}
const IDLE_SNAPSHOT: DeleteProgressSnapshot = { count: 0, label: null };
const activeTasks = new Map<number, string>();
const listeners = new Set<() => void>();
let nextTaskId = 1;
let snapshot: DeleteProgressSnapshot = IDLE_SNAPSHOT;
function publish(): void {
if (activeTasks.size === 0) {
snapshot = IDLE_SNAPSHOT;
} else {
const [oldestLabel] = activeTasks.values();
snapshot = { count: activeTasks.size, label: oldestLabel ?? null };
}
for (const listener of listeners) listener();
}
export function subscribeDeleteProgress(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getDeleteProgressSnapshot(): DeleteProgressSnapshot {
return snapshot;
}
/** Register a delete as started. Returns the function that ends it. */
export function beginDeleteTask(label: string): () => void {
const taskId = nextTaskId++;
activeTasks.set(taskId, label);
publish();
let ended = false;
return () => {
if (ended) return;
ended = true;
activeTasks.delete(taskId);
publish();
};
}
/** Run a delete request with the global progress indicator active. */
export async function trackDelete<T>(label: string, run: () => Promise<T>): Promise<T> {
const end = beginDeleteTask(label);
try {
return await run();
} finally {
end();
}
}
/** Test helper: drop every in-flight task so cases don't leak into each other. */
export function resetDeleteProgress(): void {
activeTasks.clear();
publish();
}