feat(stats): speed up session maintenance and improve stats UI (#111)

This commit is contained in:
2026-06-08 02:20:52 -07:00
committed by GitHub
parent e6a16a069b
commit 311f1e8ee5
108 changed files with 7441 additions and 729 deletions
+47 -6
View File
@@ -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>
);
}