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 {
position: absolute;
top: 16px;
top: 80px;
right: 16px;
padding: 6px;
border-radius: 10px;
@@ -71,8 +71,9 @@ test('buildBucketDeleteHandler deletes every session in the bucket when confirm
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[] = [];
let startedIds: number[] | null = null;
const bucket = makeBucket([
makeSession({ sessionId: 11 }),
@@ -91,8 +92,9 @@ test('buildBucketDeleteHandler signals onStart after confirm, before deleting',
events.push('confirm');
return true;
},
onStart: (count) => {
events.push(`start:${count}`);
onStart: (ids) => {
startedIds = ids;
events.push('start');
},
onSuccess: () => {
events.push('success');
@@ -104,7 +106,8 @@ test('buildBucketDeleteHandler signals onStart after confirm, before deleting',
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 () => {
+27 -13
View File
@@ -30,7 +30,7 @@ export interface BucketDeleteDeps {
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
confirm: (title: string, count: number) => boolean | Promise<boolean>;
/** Called once confirmation passes, just before the delete request begins. */
onStart?: (count: number) => void;
onStart?: (sessionIds: number[]) => void;
onSuccess: (deletedIds: number[]) => void;
onError: (message: string) => void;
}
@@ -48,7 +48,7 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<
const ids = bucket.sessions.map((s) => s.sessionId);
try {
if (!(await confirm(title, ids.length))) return;
onStart?.(ids.length);
onStart?.(ids);
await client.deleteSessions(ids);
onSuccess(ids);
} catch (err) {
@@ -74,9 +74,8 @@ export function SessionsTab({
const [search, setSearch] = useState('');
const [visibleSessions, setVisibleSessions] = useState<SessionSummary[]>([]);
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 [deletingCount, setDeletingCount] = useState(0);
useEffect(() => {
setVisibleSessions(sessions);
@@ -135,8 +134,11 @@ export function SessionsTab({
if (!confirmed) return;
setDeleteError(null);
setDeletingSessionId(session.sessionId);
setDeletingCount(1);
setDeletingSessionIds((prev) => {
const next = new Set(prev);
next.add(session.sessionId);
return next;
});
try {
await apiClient.deleteSession(session.sessionId);
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
@@ -144,8 +146,11 @@ export function SessionsTab({
} catch (err) {
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
} finally {
setDeletingSessionId(null);
setDeletingCount(0);
setDeletingSessionIds((prev) => {
const next = new Set(prev);
next.delete(session.sessionId);
return next;
});
}
};
@@ -156,7 +161,12 @@ export function SessionsTab({
bucket,
apiClient,
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) => {
const deleted = new Set(ids);
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
@@ -174,7 +184,11 @@ export function SessionsTab({
await handler();
} finally {
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)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
deleteDisabled={deletingSessionIds.has(s.sessionId)}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
@@ -288,7 +302,7 @@ export function SessionsTab({
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
}
onDelete={() => void handleDeleteSession(s)}
deleteDisabled={deletingSessionId === s.sessionId}
deleteDisabled={deletingSessionIds.has(s.sessionId)}
onNavigateToMediaDetail={onNavigateToMediaDetail}
/>
{expandedId === s.sessionId && (
@@ -315,7 +329,7 @@ export function SessionsTab({
</div>
)}
<DeleteProgressToast count={deletingCount} />
<DeleteProgressToast count={deletingSessionIds.size} />
</div>
);
}
+7 -1
View File
@@ -52,7 +52,13 @@ export function useCoverImages(sessions: SessionSummary[]): CoverImageMap {
}, getCoverRetryDelayMs(attempt));
}
setImages({});
if (requests.animeIds.length === 0 && requests.videoIds.length === 0) {
setImages({});
return () => {
cancelled = true;
};
}
void load(requests.animeIds, requests.videoIds, 0);
return () => {
+1
View File
@@ -5,6 +5,7 @@ export function appendCoverRetryToken(src: string, retryToken = 0): string {
const normalizedToken = String(Math.trunc(retryToken));
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');
url.searchParams.set(COVER_RETRY_PARAM, normalizedToken);
if (src.startsWith('/')) {