fix(stats): track deleting sessions by ID set, not count

- Replace deletingSessionId/deletingCount with a Set<number> so concurrent deletes disable the correct rows
- Pass session IDs (not count) to onStart callback for accurate per-row disable state
- Skip cover image fetch when sessions list is empty
- Nudge mining-frame toast down to avoid overlap with other OSD elements
This commit is contained in:
2026-06-04 22:41:51 -07:00
parent b343f1cf5c
commit 077aa89eb8
5 changed files with 43 additions and 19 deletions
+1 -1
View File
@@ -148,7 +148,7 @@ body:focus-visible,
.mining-image-toast { .mining-image-toast {
position: absolute; position: absolute;
top: 16px; top: 80px;
right: 16px; right: 16px;
padding: 6px; padding: 6px;
border-radius: 10px; border-radius: 10px;
@@ -71,8 +71,9 @@ test('buildBucketDeleteHandler deletes every session in the bucket when confirm
assert.equal(onErrorCalled, false); assert.equal(onErrorCalled, false);
}); });
test('buildBucketDeleteHandler signals onStart after confirm, before deleting', async () => { test('buildBucketDeleteHandler signals deleted session IDs after confirm, before deleting', async () => {
const events: string[] = []; const events: string[] = [];
let startedIds: number[] | null = null;
const bucket = makeBucket([ const bucket = makeBucket([
makeSession({ sessionId: 11 }), makeSession({ sessionId: 11 }),
@@ -91,8 +92,9 @@ test('buildBucketDeleteHandler signals onStart after confirm, before deleting',
events.push('confirm'); events.push('confirm');
return true; return true;
}, },
onStart: (count) => { onStart: (ids) => {
events.push(`start:${count}`); startedIds = ids;
events.push('start');
}, },
onSuccess: () => { onSuccess: () => {
events.push('success'); events.push('success');
@@ -104,7 +106,8 @@ test('buildBucketDeleteHandler signals onStart after confirm, before deleting',
await handler(); await handler();
assert.deepEqual(events, ['confirm', 'start:3', 'delete', 'success']); assert.deepEqual(events, ['confirm', 'start', 'delete', 'success']);
assert.deepEqual(startedIds, [11, 22, 33]);
}); });
test('buildBucketDeleteHandler does not call onStart when confirm returns false', async () => { test('buildBucketDeleteHandler does not call onStart when confirm returns false', async () => {
+27 -13
View File
@@ -30,7 +30,7 @@ export interface BucketDeleteDeps {
apiClient: { deleteSessions: (ids: number[]) => Promise<void> }; apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
confirm: (title: string, count: number) => boolean | Promise<boolean>; confirm: (title: string, count: number) => boolean | Promise<boolean>;
/** Called once confirmation passes, just before the delete request begins. */ /** Called once confirmation passes, just before the delete request begins. */
onStart?: (count: number) => void; onStart?: (sessionIds: number[]) => void;
onSuccess: (deletedIds: number[]) => void; onSuccess: (deletedIds: number[]) => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
@@ -48,7 +48,7 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<
const ids = bucket.sessions.map((s) => s.sessionId); const ids = bucket.sessions.map((s) => s.sessionId);
try { try {
if (!(await confirm(title, ids.length))) return; if (!(await confirm(title, ids.length))) return;
onStart?.(ids.length); onStart?.(ids);
await client.deleteSessions(ids); await client.deleteSessions(ids);
onSuccess(ids); onSuccess(ids);
} catch (err) { } catch (err) {
@@ -74,9 +74,8 @@ export function SessionsTab({
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]); const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null); const [deletingSessionIds, setDeletingSessionIds] = useState<Set<number>>(() => new Set());
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null); const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
const [deletingCount, setDeletingCount] = useState(0);
useEffect(() => { useEffect(() => {
setVisibleSessions(sessions); setVisibleSessions(sessions);
@@ -135,8 +134,11 @@ export function SessionsTab({
if (!confirmed) return; if (!confirmed) return;
setDeleteError(null); setDeleteError(null);
setDeletingSessionId(session.sessionId); setDeletingSessionIds((prev) => {
setDeletingCount(1); const next = new Set(prev);
next.add(session.sessionId);
return next;
});
try { try {
await apiClient.deleteSession(session.sessionId); await apiClient.deleteSession(session.sessionId);
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId)); setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
@@ -144,8 +146,11 @@ export function SessionsTab({
} catch (err) { } catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.'); setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally { } finally {
setDeletingSessionId(null); setDeletingSessionIds((prev) => {
setDeletingCount(0); const next = new Set(prev);
next.delete(session.sessionId);
return next;
});
} }
}; };
@@ -156,7 +161,12 @@ export function SessionsTab({
bucket, bucket,
apiClient, apiClient,
confirm: confirmBucketDelete, confirm: confirmBucketDelete,
onStart: (count) => setDeletingCount(count), onStart: (ids) =>
setDeletingSessionIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.add(id);
return next;
}),
onSuccess: (ids) => { onSuccess: (ids) => {
const deleted = new Set(ids); const deleted = new Set(ids);
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId))); setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
@@ -174,7 +184,11 @@ export function SessionsTab({
await handler(); await handler();
} finally { } finally {
setDeletingBucketKey(null); setDeletingBucketKey(null);
setDeletingCount(0); setDeletingSessionIds((prev) => {
const next = new Set(prev);
for (const session of bucket.sessions) next.delete(session.sessionId);
return next;
});
} }
}; };
@@ -219,7 +233,7 @@ export function SessionsTab({
setExpandedId(expandedId === s.sessionId ? null : s.sessionId) setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
} }
onDelete={() => void handleDeleteSession(s)} onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId} deleteDisabled={deletingSessionIds.has(s.sessionId)}
onNavigateToMediaDetail={onNavigateToMediaDetail} onNavigateToMediaDetail={onNavigateToMediaDetail}
/> />
{expandedId === s.sessionId && ( {expandedId === s.sessionId && (
@@ -288,7 +302,7 @@ export function SessionsTab({
setExpandedId(expandedId === s.sessionId ? null : s.sessionId) setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
} }
onDelete={() => void handleDeleteSession(s)} onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId} deleteDisabled={deletingSessionIds.has(s.sessionId)}
onNavigateToMediaDetail={onNavigateToMediaDetail} onNavigateToMediaDetail={onNavigateToMediaDetail}
/> />
{expandedId === s.sessionId && ( {expandedId === s.sessionId && (
@@ -315,7 +329,7 @@ export function SessionsTab({
</div> </div>
)} )}
<DeleteProgressToast count={deletingCount} /> <DeleteProgressToast count={deletingSessionIds.size} />
</div> </div>
); );
} }
+7 -1
View File
@@ -52,7 +52,13 @@ export function useCoverImages(sessions: SessionSummary[]): CoverImageMap {
}, getCoverRetryDelayMs(attempt)); }, getCoverRetryDelayMs(attempt));
} }
setImages({}); if (requests.animeIds.length === 0 && requests.videoIds.length === 0) {
setImages({});
return () => {
cancelled = true;
};
}
void load(requests.animeIds, requests.videoIds, 0); void load(requests.animeIds, requests.videoIds, 0);
return () => { return () => {
+1
View File
@@ -5,6 +5,7 @@ export function appendCoverRetryToken(src: string, retryToken = 0): string {
const normalizedToken = String(Math.trunc(retryToken)); const normalizedToken = String(Math.trunc(retryToken));
try { try {
// Dummy base lets URL parse relative API paths; it is never returned as a real host.
const url = new URL(src, 'http://subminer.local'); const url = new URL(src, 'http://subminer.local');
url.searchParams.set(COVER_RETRY_PARAM, normalizedToken); url.searchParams.set(COVER_RETRY_PARAM, normalizedToken);
if (src.startsWith('/')) { if (src.startsWith('/')) {