mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
feat(stats): speed up session maintenance and improve stats UI (#111)
This commit is contained in:
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SessionBucket } from '../../lib/session-grouping';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
import { buildBucketDeleteHandler } from './SessionsTab';
|
||||
import {
|
||||
buildBucketDeleteHandler,
|
||||
decrementDeletingSessionCounts,
|
||||
incrementDeletingSessionCounts,
|
||||
} from './SessionsTab';
|
||||
|
||||
function makeSession(over: Partial<SessionSummary>): SessionSummary {
|
||||
return {
|
||||
@@ -71,6 +75,84 @@ test('buildBucketDeleteHandler deletes every session in the bucket when confirm
|
||||
assert.equal(onErrorCalled, false);
|
||||
});
|
||||
|
||||
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 }),
|
||||
makeSession({ sessionId: 22 }),
|
||||
makeSession({ sessionId: 33 }),
|
||||
]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: {
|
||||
deleteSessions: async () => {
|
||||
events.push('delete');
|
||||
},
|
||||
},
|
||||
confirm: () => {
|
||||
events.push('confirm');
|
||||
return true;
|
||||
},
|
||||
onStart: (ids) => {
|
||||
startedIds = ids;
|
||||
events.push('start');
|
||||
},
|
||||
onSuccess: () => {
|
||||
events.push('success');
|
||||
},
|
||||
onError: () => {
|
||||
events.push('error');
|
||||
},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.deepEqual(events, ['confirm', 'start', 'delete', 'success']);
|
||||
assert.deepEqual(startedIds, [11, 22, 33]);
|
||||
});
|
||||
|
||||
test('deleting session counts keep rows disabled during overlapping delete flows', () => {
|
||||
let deleting = new Map<number, number>();
|
||||
deleting = incrementDeletingSessionCounts(deleting, [11]);
|
||||
deleting = incrementDeletingSessionCounts(deleting, [11, 22]);
|
||||
|
||||
assert.equal(deleting.get(11), 2);
|
||||
assert.equal(deleting.get(22), 1);
|
||||
|
||||
deleting = decrementDeletingSessionCounts(deleting, [11]);
|
||||
|
||||
assert.equal(deleting.get(11), 1);
|
||||
assert.equal(deleting.has(22), true);
|
||||
|
||||
deleting = decrementDeletingSessionCounts(deleting, [11, 22]);
|
||||
|
||||
assert.equal(deleting.size, 0);
|
||||
});
|
||||
|
||||
test('buildBucketDeleteHandler does not call onStart when confirm returns false', async () => {
|
||||
let startCalled = false;
|
||||
|
||||
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: { deleteSessions: async () => {} },
|
||||
confirm: () => false,
|
||||
onStart: () => {
|
||||
startCalled = true;
|
||||
},
|
||||
onSuccess: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.equal(startCalled, false);
|
||||
});
|
||||
|
||||
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
|
||||
let deleteCalled = false;
|
||||
let successCalled = false;
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
@@ -28,6 +29,8 @@ export interface BucketDeleteDeps {
|
||||
bucket: SessionBucket;
|
||||
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?: (sessionIds: number[]) => void;
|
||||
onSuccess: (deletedIds: number[]) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
@@ -39,12 +42,13 @@ export interface BucketDeleteDeps {
|
||||
* rendering the full SessionsTab or mocking React state.
|
||||
*/
|
||||
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
|
||||
const { bucket, apiClient: client, confirm, onSuccess, onError } = deps;
|
||||
const { bucket, apiClient: client, confirm, onStart, onSuccess, onError } = deps;
|
||||
return async () => {
|
||||
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
||||
const ids = bucket.sessions.map((s) => s.sessionId);
|
||||
try {
|
||||
if (!(await confirm(title, ids.length))) return;
|
||||
onStart?.(ids);
|
||||
await client.deleteSessions(ids);
|
||||
onSuccess(ids);
|
||||
} catch (err) {
|
||||
@@ -53,6 +57,29 @@ export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<
|
||||
};
|
||||
}
|
||||
|
||||
export function incrementDeletingSessionCounts(
|
||||
prev: ReadonlyMap<number, number>,
|
||||
ids: number[],
|
||||
): Map<number, number> {
|
||||
const next = new Map(prev);
|
||||
for (const id of ids) next.set(id, (next.get(id) ?? 0) + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function decrementDeletingSessionCounts(
|
||||
prev: ReadonlyMap<number, number>,
|
||||
ids: number[],
|
||||
): Map<number, number> {
|
||||
const next = new Map(prev);
|
||||
for (const id of ids) {
|
||||
const count = next.get(id);
|
||||
if (!count) continue;
|
||||
if (count <= 1) next.delete(id);
|
||||
else next.set(id, count - 1);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
interface SessionsTabProps {
|
||||
initialSessionId?: number | null;
|
||||
onClearInitialSession?: () => void;
|
||||
@@ -70,7 +97,9 @@ 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<Map<number, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -119,6 +148,14 @@ export function SessionsTab({
|
||||
});
|
||||
};
|
||||
|
||||
const markDeleting = (ids: number[]) => {
|
||||
setDeletingSessionIds((prev) => incrementDeletingSessionCounts(prev, ids));
|
||||
};
|
||||
|
||||
const unmarkDeleting = (ids: number[]) => {
|
||||
setDeletingSessionIds((prev) => decrementDeletingSessionCounts(prev, ids));
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (session: SessionSummary) => {
|
||||
let confirmed = false;
|
||||
try {
|
||||
@@ -130,7 +167,7 @@ export function SessionsTab({
|
||||
if (!confirmed) return;
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingSessionId(session.sessionId);
|
||||
markDeleting([session.sessionId]);
|
||||
try {
|
||||
await apiClient.deleteSession(session.sessionId);
|
||||
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
|
||||
@@ -138,7 +175,7 @@ export function SessionsTab({
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||
} finally {
|
||||
setDeletingSessionId(null);
|
||||
unmarkDeleting([session.sessionId]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,6 +186,7 @@ export function SessionsTab({
|
||||
bucket,
|
||||
apiClient,
|
||||
confirm: confirmBucketDelete,
|
||||
onStart: (ids) => markDeleting(ids),
|
||||
onSuccess: (ids) => {
|
||||
const deleted = new Set(ids);
|
||||
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
|
||||
@@ -166,6 +204,7 @@ export function SessionsTab({
|
||||
await handler();
|
||||
} finally {
|
||||
setDeletingBucketKey(null);
|
||||
unmarkDeleting(bucket.sessions.map((session) => session.sessionId));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -210,7 +249,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 && (
|
||||
@@ -279,7 +318,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 && (
|
||||
@@ -305,6 +344,8 @@ export function SessionsTab({
|
||||
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteProgressToast count={deletingSessionIds.size} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user