From 7e2be9ab73264fb954d7ca9a1116a12d2d1c9260 Mon Sep 17 00:00:00 2001 From: Rob Martin Date: Mon, 5 Oct 2026 17:57:48 -0600 Subject: [PATCH] fix(activity): Clear Completed leaves out requests that are still pending --- .../components/activity/ActivitySidebar.tsx | 37 +------ .../src/components/activity/clearCompleted.ts | 50 +++++++++ src/frontend/src/tests/clearCompleted.test.ts | 101 ++++++++++++++++++ 3 files changed, 156 insertions(+), 32 deletions(-) create mode 100644 src/frontend/src/components/activity/clearCompleted.ts create mode 100644 src/frontend/src/tests/clearCompleted.test.ts diff --git a/src/frontend/src/components/activity/ActivitySidebar.tsx b/src/frontend/src/components/activity/ActivitySidebar.tsx index 45b88b65..b85d6736 100644 --- a/src/frontend/src/components/activity/ActivitySidebar.tsx +++ b/src/frontend/src/components/activity/ActivitySidebar.tsx @@ -9,6 +9,7 @@ import { ActivityCard } from './ActivityCard'; import type { DownloadStatusKey } from './activityMappers'; import { downloadToActivityItem, linkedDownloadIdForRequest } from './activityMappers'; import type { ActivityItem } from './activityTypes'; +import { getClearCompletedTargets } from './clearCompleted'; interface ActivitySidebarProps { isOpen: boolean; @@ -483,38 +484,10 @@ export const ActivitySidebar = ({ const hasUserFilter = isAdmin && availableUsers.length > 1; - const clearCompletedTargets = useMemo(() => { - const targets: ActivityDismissTarget[] = []; - const seen = new Set(); - - visibleItems.forEach((item) => { - const isTerminalDownload = - item.kind === 'download' && - (item.visualStatus === 'complete' || - item.visualStatus === 'error' || - item.visualStatus === 'cancelled'); - - if (!isTerminalDownload || !item.downloadBookId) { - return; - } - - const downloadKey = `download:${item.downloadBookId}`; - if (!seen.has(downloadKey)) { - seen.add(downloadKey); - targets.push({ itemType: 'download', itemKey: downloadKey }); - } - - if (item.requestId) { - const requestKey = `request:${item.requestId}`; - if (!seen.has(requestKey)) { - seen.add(requestKey); - targets.push({ itemType: 'request', itemKey: requestKey }); - } - } - }); - - return targets; - }, [visibleItems]); + const clearCompletedTargets = useMemo( + () => getClearCompletedTargets(visibleItems, visibleRequestItems), + [visibleItems, visibleRequestItems], + ); const visibleCategoryOrder = useMemo( () => getVisibleCategoryOrder(effectiveActiveTab), diff --git a/src/frontend/src/components/activity/clearCompleted.ts b/src/frontend/src/components/activity/clearCompleted.ts new file mode 100644 index 00000000..a2988dde --- /dev/null +++ b/src/frontend/src/components/activity/clearCompleted.ts @@ -0,0 +1,50 @@ +import type { ActivityDismissTarget } from './ActivitySidebar'; +import type { ActivityItem } from './activityTypes'; + +/** + * Items the "Clear Completed" button dismisses: every finished download, plus the request + * linked to it once that request is itself finished. The server only dismisses finished + * requests and rejects the whole batch if one is still pending, which a restart can cause + * by reopening the request of a download that already ended. + */ +export const getClearCompletedTargets = ( + visibleItems: ActivityItem[], + requestItems: ActivityItem[], +): ActivityDismissTarget[] => { + const pendingRequestIds = new Set(); + requestItems.forEach((item) => { + if (typeof item.requestId === 'number' && item.requestRecord?.status === 'pending') { + pendingRequestIds.add(item.requestId); + } + }); + const targets: ActivityDismissTarget[] = []; + const seen = new Set(); + + visibleItems.forEach((item) => { + const isTerminalDownload = + item.kind === 'download' && + (item.visualStatus === 'complete' || + item.visualStatus === 'error' || + item.visualStatus === 'cancelled'); + + if (!isTerminalDownload || !item.downloadBookId) { + return; + } + + const downloadKey = `download:${item.downloadBookId}`; + if (!seen.has(downloadKey)) { + seen.add(downloadKey); + targets.push({ itemType: 'download', itemKey: downloadKey }); + } + + if (item.requestId && !pendingRequestIds.has(item.requestId)) { + const requestKey = `request:${item.requestId}`; + if (!seen.has(requestKey)) { + seen.add(requestKey); + targets.push({ itemType: 'request', itemKey: requestKey }); + } + } + }); + + return targets; +}; diff --git a/src/frontend/src/tests/clearCompleted.test.ts b/src/frontend/src/tests/clearCompleted.test.ts new file mode 100644 index 00000000..02e89e89 --- /dev/null +++ b/src/frontend/src/tests/clearCompleted.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; + +import type { ActivityItem } from '../components/activity/activityTypes'; +import { getClearCompletedTargets } from '../components/activity/clearCompleted'; +import type { RequestRecord } from '../types'; + +const makeRequestRecord = (id: number, status: RequestRecord['status']): RequestRecord => ({ + id, + user_id: 7, + status, + source_hint: 'prowlarr', + content_type: 'ebook', + request_level: 'book', + policy_mode: 'request_book', + book_data: { title: 'Title', author: 'Author' }, + release_data: null, + note: null, + admin_note: null, + reviewed_by: null, + reviewed_at: null, + created_at: '2026-02-13T12:00:00Z', + updated_at: '2026-02-13T12:00:00Z', + username: 'alice', +}); + +const makeDownloadItem = ( + downloadBookId: string, + requestId: number | undefined, + overrides: Partial = {}, +): ActivityItem => ({ + id: `download-${downloadBookId}`, + kind: 'download', + visualStatus: 'complete', + title: 'Title', + author: 'Author', + metaLine: 'EPUB', + statusLabel: 'Complete', + timestamp: 1, + downloadBookId, + requestId, + ...overrides, +}); + +const makeRequestItem = (requestId: number, status: RequestRecord['status']): ActivityItem => ({ + id: `request-${requestId}`, + kind: 'request', + visualStatus: status, + title: 'Title', + author: 'Author', + metaLine: 'Book request', + statusLabel: status, + timestamp: 1, + requestId, + requestRecord: makeRequestRecord(requestId, status), +}); + +describe('getClearCompletedTargets', () => { + it('dismisses a finished download together with its finished request', () => { + const targets = getClearCompletedTargets( + [makeDownloadItem('a', 5)], + [makeRequestItem(5, 'fulfilled')], + ); + + expect(targets).toEqual([ + { itemType: 'download', itemKey: 'download:a' }, + { itemType: 'request', itemKey: 'request:5' }, + ]); + }); + + it('dismisses the download but not a request that is still pending', () => { + // The server refuses to dismiss a pending request, and one refusal fails the whole batch. + const targets = getClearCompletedTargets( + [makeDownloadItem('a', 5), makeDownloadItem('b', 6)], + [makeRequestItem(5, 'pending'), makeRequestItem(6, 'fulfilled')], + ); + + expect(targets).toEqual([ + { itemType: 'download', itemKey: 'download:a' }, + { itemType: 'download', itemKey: 'download:b' }, + { itemType: 'request', itemKey: 'request:6' }, + ]); + }); + + it('still dismisses the request when the list has no record of it', () => { + const targets = getClearCompletedTargets([makeDownloadItem('a', 9)], []); + + expect(targets).toEqual([ + { itemType: 'download', itemKey: 'download:a' }, + { itemType: 'request', itemKey: 'request:9' }, + ]); + }); + + it('leaves unfinished downloads alone', () => { + const targets = getClearCompletedTargets( + [makeDownloadItem('a', undefined, { visualStatus: 'downloading' })], + [], + ); + + expect(targets).toEqual([]); + }); +});