feat(activity): Clear Completed also clears finished requests with no download (#1650)

A request that auto-download closes because the book is already in the
library never gets a download, so Clear Completed skipped it and those
requests piled up under Complete. The button also disappeared once the
last finished download was cleared, leaving no way to clear them.

ActivitySidebar.tsx: hasTerminalDownloadItems is dropped; the button
shows whenever getClearCompletedTargets returns anything, and on the
Requests tab as well as Downloads and All.

clearCompleted.ts: a request card with no download of its own is a
target when its request is fulfilled with no active delivery_state
(queued, resolving, locating, downloading), or cancelled. Pending and
rejected requests are left alone. The server already treats any
fulfilled request as terminal in _request_terminal_status, so the client
rule is the stricter of the two.

docs/users-and-requests.md: a line under Clearing the activity list
saying what the button clears.

Tests: two new cases in clearCompleted.test.ts, one for a closed request
and a cancelled one with no download, one that pending, rejected and
still-downloading requests are not touched.

Builds on #1438.



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* **Clear Completed** can appear on the Requests tab, as well as
Downloads and All, when there are items to clear.
* Clearing completed items removes finished downloads and their
requests, fulfilled requests that are no longer in progress, and
cancelled requests without downloads. Pending and rejected requests
remain.
* **Documentation**
* Updated the help documentation to explain what **Clear Completed**
removes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
splitsec2 authored and GitHub committed 2026-11-08 14:52:19 -07:00
1 parent 371ac75954
commit c38f4c6522
4 files changed
+57 -13

No files matched your search

+4
View File
@@ -69,6 +69,10 @@ Admins can override the default ebook/audiobook modes and request rules for indi
---
## Clearing the activity list
**Clear Completed** in the activity list clears finished downloads and their requests, and also finished requests that never had a download, such as a request closed because the book was already in the library. Pending and rejected requests are left alone.
## Requests
The request system controls whether users can download directly or need admin approval first.
@@ -379,17 +379,6 @@ export const ActivitySidebar = ({
};
}, [downloadItems, visibleRequestItems]);
const hasTerminalDownloadItems = useMemo(
() =>
mergedDownloadItems.some(
(item) =>
item.visualStatus === 'complete' ||
item.visualStatus === 'error' ||
item.visualStatus === 'cancelled',
),
[mergedDownloadItems],
);
const allItems = useMemo(() => {
const combined = dedupeById([...mergedDownloadItems, ...mergedRequestItems]);
return combined.toSorted((a, b) => b.timestamp - a.timestamp);
@@ -958,8 +947,9 @@ export const ActivitySidebar = ({
})()}
</div>
{(effectiveActiveTab === 'downloads' || effectiveActiveTab === 'all') &&
hasTerminalDownloadItems &&
{(effectiveActiveTab === 'downloads' ||
effectiveActiveTab === 'all' ||
effectiveActiveTab === 'requests') &&
clearCompletedTargets.length > 0 && (
<div
className="flex items-center justify-center border-t p-3"
@@ -1,11 +1,19 @@
import type { ActivityDismissTarget } from './ActivitySidebar';
import type { ActivityItem } from './activityTypes';
// A fulfilled request whose download is still under way is not finished.
const ACTIVE_DELIVERY_STATES = new Set(['queued', 'resolving', 'locating', 'downloading']);
/**
* 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.
*
* Also every finished request card that has no download of its own: an approved request
* nothing is still downloading for (auto-download closes a request whose book is already
* in the library without downloading anything), or a cancelled one. Rejected requests
* stay; they have their own view.
*/
export const getClearCompletedTargets = (
visibleItems: ActivityItem[],
@@ -21,6 +29,20 @@ export const getClearCompletedTargets = (
const seen = new Set<string>();
visibleItems.forEach((item) => {
if (item.kind === 'request' && typeof item.requestId === 'number') {
const record = item.requestRecord;
const finished =
record?.status === 'cancelled' ||
(record?.status === 'fulfilled' &&
!ACTIVE_DELIVERY_STATES.has(record.delivery_state ?? ''));
const requestKey = `request:${item.requestId}`;
if (finished && !pendingRequestIds.has(item.requestId) && !seen.has(requestKey)) {
seen.add(requestKey);
targets.push({ itemType: 'request', itemKey: requestKey });
}
return;
}
const isTerminalDownload =
item.kind === 'download' &&
(item.visualStatus === 'complete' ||
@@ -98,4 +98,32 @@ describe('getClearCompletedTargets', () => {
expect(targets).toEqual([]);
});
it('clears a finished request that never had a download', () => {
const closed = makeRequestItem(30, 'fulfilled');
closed.requestRecord = Object.assign({}, closed.requestRecord, {
delivery_state: 'complete' as const,
admin_note: '[auto] Already in the library.',
});
const cancelled = makeRequestItem(31, 'cancelled');
const targets = getClearCompletedTargets([closed, cancelled], [closed, cancelled]);
expect(targets).toEqual([
{ itemType: 'request', itemKey: 'request:30' },
{ itemType: 'request', itemKey: 'request:31' },
]);
});
it('leaves pending, rejected and still-downloading requests alone', () => {
const pending = makeRequestItem(40, 'pending');
const rejected = makeRequestItem(41, 'rejected');
const downloading = makeRequestItem(42, 'fulfilled');
downloading.requestRecord = Object.assign({}, downloading.requestRecord, {
delivery_state: 'downloading' as const,
});
const items = [pending, rejected, downloading];
expect(getClearCompletedTargets(items, items)).toEqual([]);
});
});