mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
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:
+16
-17
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
};
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user