Download history refactor pt3 (#706)

- Added canonical per-user visibility of requests and downloads via new
activity view table. Users get fully independent activity and history
views, while admins still see all.
- Replaces janky frontend + backend combination
This commit is contained in:
Alex
2026-03-05 19:53:22 +00:00
committed by GitHub
parent 447ed1a924
commit 4b0d1aef13
20 changed files with 1464 additions and 627 deletions
+16 -17
View File
@@ -135,17 +135,6 @@ type PendingOnBehalfDownload =
actingAsUser: ActingAsUserSelection;
};
const mergeTerminalBucket = (
persistedBucket: Record<string, Book> | undefined,
realtimeBucket: Record<string, Book> | undefined
): Record<string, Book> | undefined => {
const merged = {
...(persistedBucket || {}),
...(realtimeBucket || {}),
};
return Object.keys(merged).length > 0 ? merged : undefined;
};
function App() {
const { toasts, showToast, removeToast } = useToast();
const { socket } = useSocket();
@@ -263,10 +252,12 @@ function App() {
requestItems,
dismissedActivityKeys,
historyItems,
activityHistoryLoaded,
pendingRequestCount,
isActivitySnapshotLoading,
activityHistoryLoading,
activityHistoryHasMore,
prefetchActivityHistory,
refreshActivitySnapshot,
resetActivity,
handleActivityTabChange,
@@ -319,9 +310,9 @@ function App() {
};
}, [currentStatus, dismissedDownloadTaskIds]);
// Use real-time buckets for active work and merge persisted terminal buckets
// so completed/errored entries survive restarts. Filter out dismissed items
// so the sidebar counts stay consistent with the activity panel.
// Use real-time buckets for active work and persisted activity snapshot
// buckets for terminal history. Filter out dismissed items so the sidebar
// counts stay consistent with the activity panel.
const activitySidebarStatus = useMemo<StatusData>(() => {
const filterDismissed = (
bucket: Record<string, Book> | undefined
@@ -338,9 +329,9 @@ function App() {
resolving: currentStatus.resolving,
locating: currentStatus.locating,
downloading: currentStatus.downloading,
complete: filterDismissed(mergeTerminalBucket(activityStatus.complete, currentStatus.complete)),
error: filterDismissed(mergeTerminalBucket(activityStatus.error, currentStatus.error)),
cancelled: filterDismissed(mergeTerminalBucket(activityStatus.cancelled, currentStatus.cancelled)),
complete: filterDismissed(activityStatus.complete),
error: filterDismissed(activityStatus.error),
cancelled: filterDismissed(activityStatus.cancelled),
};
}, [activityStatus, currentStatus, dismissedDownloadTaskIds]);
@@ -430,6 +421,13 @@ function App() {
const [sidebarPinnedOpen, setSidebarPinnedOpen] = useState(false);
const [headerHeight, setHeaderHeight] = useState(0);
const headerObserverRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
if (!downloadsSidebarOpen) {
return;
}
prefetchActivityHistory();
}, [downloadsSidebarOpen, prefetchActivityHistory]);
const headerRef = useCallback((el: HTMLDivElement | null) => {
if (headerObserverRef.current) {
headerObserverRef.current.disconnect();
@@ -1696,6 +1694,7 @@ function App() {
requestItems={requestItems}
dismissedItemKeys={dismissedActivityKeys}
historyItems={historyItems}
historyLoaded={activityHistoryLoaded}
historyHasMore={activityHistoryHasMore}
historyLoading={activityHistoryLoading}
onHistoryLoadMore={handleActivityHistoryLoadMore}
+12 -9
View File
@@ -402,17 +402,20 @@ function ShimmerBlock({ className }: { className: string }) {
}
// Loading skeleton for releases - matches ReleaseRow layout
// Renders enough rows to fill the container, fading out at the bottom via a gradient mask
function ReleaseSkeleton() {
// Render enough rows to cover tall viewports; overflow is hidden by the mask
const rows = 8;
return (
<div className="divide-y divide-gray-200/60 dark:divide-gray-800/60">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="px-5 py-2"
style={{
opacity: 1 - (i - 1) * 0.15, // Fade out lower rows
}}
>
<div
className="divide-y divide-gray-200/60 dark:divide-gray-800/60 overflow-hidden"
style={{
maskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)',
WebkitMaskImage: 'linear-gradient(to bottom, black 40%, transparent 100%)',
}}
>
{Array.from({ length: rows }, (_, i) => (
<div key={i} className="px-5 py-2">
<div className="grid grid-cols-[auto_1fr_auto] sm:grid-cols-[auto_minmax(0,2fr)_60px_80px_80px_auto] items-center gap-2 sm:gap-3">
{/* Thumbnail skeleton */}
<ShimmerBlock className="w-7 h-10 sm:w-10 sm:h-14" />
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type WheelEvent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type WheelEvent } from 'react';
import { RequestRecord, StatusData } from '../../types';
import { downloadToActivityItem, DownloadStatusKey } from './activityMappers';
import { ActivityItem } from './activityTypes';
@@ -17,6 +17,7 @@ interface ActivitySidebarProps {
requestItems: ActivityItem[];
dismissedItemKeys?: string[];
historyItems?: ActivityItem[];
historyLoaded?: boolean;
historyHasMore?: boolean;
historyLoading?: boolean;
onHistoryLoadMore?: () => void;
@@ -247,6 +248,7 @@ export const ActivitySidebar = ({
requestItems,
dismissedItemKeys = [],
historyItems = [],
historyLoaded = false,
historyHasMore = false,
historyLoading = false,
onHistoryLoadMore,
@@ -274,6 +276,10 @@ export const ActivitySidebar = ({
() => new Set(dismissedItemKeys),
[dismissedItemKeys]
);
const handleTabChange = useCallback((nextTab: ActivityTabKey) => {
setActiveTab(nextTab);
onActiveTabChange?.(nextTab);
}, [onActiveTabChange]);
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1024px)');
@@ -292,13 +298,9 @@ export const ActivitySidebar = ({
useEffect(() => {
if (!showRequestsTab && activeTab === 'requests') {
setActiveTab('all');
handleTabChange('all');
}
}, [showRequestsTab, activeTab]);
useEffect(() => {
onActiveTabChange?.(activeTab);
}, [activeTab, onActiveTabChange]);
}, [showRequestsTab, activeTab, handleTabChange]);
useEffect(() => {
if (activeTab === 'downloads') {
@@ -452,9 +454,10 @@ export const ActivitySidebar = ({
}
return requestStatus === 'fulfilled' && item.kind === 'request';
})
: activeTab === 'history'
? historyItems
: mergedDownloadItems;
: activeTab === 'history'
? historyItems
: mergedDownloadItems;
const isHistoryInitialLoad = activeTab === 'history' && !historyLoaded;
const availableUsers = useMemo(() => {
const userMap = new Map<string, string>();
@@ -702,12 +705,12 @@ export const ActivitySidebar = ({
)}
</Dropdown>
)}
<button
type="button"
onClick={() => setActiveTab((current) => (current === 'history' ? 'all' : 'history'))}
className={`relative h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors ${
activeTab === 'history' ? 'text-sky-600 dark:text-sky-400' : ''
}`}
<button
type="button"
onClick={() => handleTabChange(activeTab === 'history' ? 'all' : 'history')}
className={`relative h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors ${
activeTab === 'history' ? 'text-sky-600 dark:text-sky-400' : ''
}`}
title={activeTab === 'history' ? 'Back to activity' : 'Open history'}
aria-label={activeTab === 'history' ? 'Back to activity' : 'Open history'}
aria-pressed={activeTab === 'history'}
@@ -745,7 +748,7 @@ export const ActivitySidebar = ({
<button
type="button"
ref={(el) => { tabRefs.current.all = el; }}
onClick={() => setActiveTab('all')}
onClick={() => handleTabChange('all')}
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
activeTab === 'all'
? 'text-sky-600 dark:text-sky-400'
@@ -758,7 +761,7 @@ export const ActivitySidebar = ({
<button
type="button"
ref={(el) => { tabRefs.current.downloads = el; }}
onClick={() => setActiveTab('downloads')}
onClick={() => handleTabChange('downloads')}
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
activeTab === 'downloads'
? 'text-sky-600 dark:text-sky-400'
@@ -777,7 +780,7 @@ export const ActivitySidebar = ({
<button
type="button"
ref={(el) => { tabRefs.current.requests = el; }}
onClick={() => setActiveTab('requests')}
onClick={() => handleTabChange('requests')}
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
activeTab === 'requests'
? 'text-sky-600 dark:text-sky-400'
@@ -808,7 +811,7 @@ export const ActivitySidebar = ({
{activeTab === 'requests'
? isRequestsLoading ? 'Loading requests...' : 'No requests'
: activeTab === 'history'
? historyLoading ? 'Loading history...' : 'No history'
? (historyLoading || isHistoryInitialLoad) ? 'Loading history...' : 'No history'
: activeTab === 'downloads'
? 'No downloads'
: 'No activity'}
@@ -0,0 +1,27 @@
import type { ActivityItem } from './activityTypes.js';
export const dedupeHistoryItems = (items: ActivityItem[]): ActivityItem[] => {
const requestIdsWithDownloadRows = new Set<number>();
items.forEach((item) => {
if (item.kind === 'download' && typeof item.requestId === 'number') {
requestIdsWithDownloadRows.add(item.requestId);
}
});
if (!requestIdsWithDownloadRows.size) {
return items;
}
return items.filter((item) => {
if (item.kind !== 'request' || typeof item.requestId !== 'number') {
return true;
}
if (!requestIdsWithDownloadRows.has(item.requestId)) {
return true;
}
const requestStatus = item.requestRecord?.status;
return requestStatus !== 'fulfilled' && item.visualStatus !== 'fulfilled';
});
};
+13 -38
View File
@@ -16,6 +16,7 @@ import {
downloadToActivityItem,
requestToActivityItem,
} from '../components/activity';
import { dedupeHistoryItems } from '../components/activity/activityHistory.js';
const HISTORY_PAGE_SIZE = 50;
@@ -120,10 +121,12 @@ interface UseActivityResult {
requestItems: ActivityItem[];
dismissedActivityKeys: string[];
historyItems: ActivityItem[];
activityHistoryLoaded: boolean;
pendingRequestCount: number;
isActivitySnapshotLoading: boolean;
activityHistoryLoading: boolean;
activityHistoryHasMore: boolean;
prefetchActivityHistory: () => void;
refreshActivitySnapshot: () => Promise<void>;
handleActivityTabChange: (tab: 'all' | 'downloads' | 'requests' | 'history') => void;
resetActivity: () => void;
@@ -217,6 +220,13 @@ export const useActivity = ({
void refreshActivityHistory();
}, [activityHistoryLoaded, activityHistoryLoading, refreshActivityHistory]);
const prefetchActivityHistory = useCallback(() => {
if (activityHistoryLoaded || activityHistoryLoading) {
return;
}
void refreshActivityHistory();
}, [activityHistoryLoaded, activityHistoryLoading, refreshActivityHistory]);
const handleActivityHistoryLoadMore = useCallback(() => {
if (!isAuthenticated || activityHistoryLoading || !activityHistoryHasMore) {
return;
@@ -282,44 +292,7 @@ export const useActivity = ({
.map((row) => mapHistoryRowToActivityItem(row, isAdmin ? 'admin' : 'user'))
.sort((left, right) => right.timestamp - left.timestamp);
// Request history entries with attached release metadata are approval artifacts,
// not actual download outcomes. Keep history focused on concrete download results.
const nonAttachedReleaseRequestItems = mappedItems.filter((item) => {
if (item.kind !== 'request') {
return true;
}
const releaseData = item.requestRecord?.release_data;
if (!releaseData || typeof releaseData !== 'object') {
return true;
}
return Object.keys(releaseData as Record<string, unknown>).length === 0;
});
// Download dismissals already carry linked request context; hide redundant
// fulfilled-request history rows that would otherwise appear as "Approved".
const requestIdsWithDownloadRows = new Set<number>();
nonAttachedReleaseRequestItems.forEach((item) => {
if (item.kind === 'download' && typeof item.requestId === 'number') {
requestIdsWithDownloadRows.add(item.requestId);
}
});
if (!requestIdsWithDownloadRows.size) {
return nonAttachedReleaseRequestItems;
}
return nonAttachedReleaseRequestItems.filter((item) => {
if (item.kind !== 'request' || typeof item.requestId !== 'number') {
return true;
}
if (!requestIdsWithDownloadRows.has(item.requestId)) {
return true;
}
const requestStatus = item.requestRecord?.status;
return requestStatus !== 'fulfilled' && item.visualStatus !== 'fulfilled';
});
return dedupeHistoryItems(mappedItems);
},
[activityHistoryRows, isAdmin]
);
@@ -421,10 +394,12 @@ export const useActivity = ({
requestItems,
dismissedActivityKeys,
historyItems,
activityHistoryLoaded,
pendingRequestCount,
isActivitySnapshotLoading,
activityHistoryLoading,
activityHistoryHasMore,
prefetchActivityHistory,
refreshActivitySnapshot,
handleActivityTabChange,
resetActivity,
@@ -0,0 +1,88 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { ActivityItem } from '../components/activity/activityTypes.js';
import { dedupeHistoryItems } from '../components/activity/activityHistory.js';
const makeRequestItem = (overrides: Partial<ActivityItem> = {}): ActivityItem => ({
id: 'request-42',
kind: 'request',
visualStatus: 'fulfilled',
title: 'Request Title',
author: 'Request Author',
metaLine: 'Release request',
statusLabel: 'Approved',
timestamp: 1,
requestId: 42,
requestLevel: 'release',
requestRecord: {
id: 42,
user_id: 7,
status: 'fulfilled',
source_hint: 'prowlarr',
content_type: 'ebook',
request_level: 'release',
policy_mode: 'request_release',
book_data: { title: 'Request Title', author: 'Request Author' },
release_data: { source: 'prowlarr', source_id: 'release-42' },
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',
},
...overrides,
});
const makeDownloadItem = (overrides: Partial<ActivityItem> = {}): ActivityItem => ({
id: 'download-42',
kind: 'download',
visualStatus: 'complete',
title: 'Request Title',
author: 'Request Author',
metaLine: 'EPUB · 2 MB · Prowlarr',
statusLabel: 'Complete',
timestamp: 2,
downloadBookId: 'download-42',
requestId: 42,
...overrides,
});
describe('dedupeHistoryItems', () => {
it('keeps release-level request history rows when they are not duplicated by a download row', () => {
const rejectedRequest = makeRequestItem({
id: 'request-7',
visualStatus: 'rejected',
statusLabel: 'Rejected',
requestId: 7,
requestRecord: {
...makeRequestItem().requestRecord!,
id: 7,
status: 'rejected',
release_data: { source: 'prowlarr', source_id: 'release-7' },
},
});
const items = dedupeHistoryItems([rejectedRequest]);
assert.deepEqual(items, [rejectedRequest]);
});
it('drops fulfilled request history rows when a linked download row is present', () => {
const requestItem = makeRequestItem();
const downloadItem = makeDownloadItem();
const items = dedupeHistoryItems([requestItem, downloadItem]);
assert.deepEqual(items, [downloadItem]);
});
it('keeps fulfilled request history rows when no linked download row exists', () => {
const requestItem = makeRequestItem();
const items = dedupeHistoryItems([requestItem]);
assert.deepEqual(items, [requestItem]);
});
});