feat(activity): clearer cards, a Rejected view and Reopen for failed deliveries (#1618)

A batch of activity list changes from running it as an admin on my own
instance. They are separate commits that build on each other, so they
can be reviewed in order.

The cards read better. Every card gets a format pill (violet for audio,
orange for ebooks), the requester is named once, and finished cards say
how long a download took and how long ago it ended ("took 5m · 35m
ago"). A download that picked up work already under way shows a
Reattached pill.

Admins can tell what is automatic and what needs a person. A declined
request shows who declined it and when, anything with an `[auto]` note
reads "Declined automatically", and "Needs Review" becomes "No Match
Yet" with a line saying the search repeats on every sync.

There is a Rejected view next to History for admins, with Reopen on each
card and a way to hide the dead ones.

The part to read most carefully is the backend in `requests_service.py`
and `user_db.py`. An admin can now reopen a fulfilled request whose
download failed or was cancelled, and the reopen a failed download
triggers remembers which release failed, so the next search doesn't pick
it again.

I ran it on a scratch instance (when pulling them together to move from
my fork to upstream) as an admin with seeded requests and looked at the
pills, the durations, the wording and the Rejected view in a browser.
Reopening a fulfilled request I only exercised in the tests. The
frontend and Python suites, type check, ruff and basedpyright pass.


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

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
* Admins can review rejected requests in a dedicated view, hide or
restore cards, and reopen requests.
* Fulfilled requests with failed or cancelled deliveries can be
reopened; the failed release is skipped in the next automatic search.
* Activity cards now show format badges, download timing, and when a
download resumes existing work.
* **Improvements**
* Activity views clarify automatic retries and actions, and show
reviewer names for admins.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
splitsec2 authored and GitHub committed 2026-11-09 12:02:21 -06:00
1 parent be803ed5ca
commit e575ff0fcb
34 files changed
+1774 -138

No files matched your search

@@ -32,6 +32,7 @@ interface ActivityCardProps {
onRequestReject?: (requestId: number, adminNote?: string) => Promise<void> | void;
onRequestRejectConfirm?: (requestId: number, adminNote?: string) => Promise<void> | void;
onRequestReopen?: (requestId: number) => Promise<void> | void;
onRequestSetHidden?: (requestId: number, hidden: boolean) => Promise<void> | void;
onRequestDismiss?: (requestId: number) => void;
showRequestDetailsToggle?: boolean;
isRequestDetailsOpen?: boolean;
@@ -69,6 +70,19 @@ const IconButton = ({
</button>
);
// Fork: one colour per format, picked from hues the status badges do not use.
const FORMAT_PILL_CLASSES: Record<'audiobook' | 'ebook', string> = {
audiobook: 'bg-violet-100 text-violet-800 dark:bg-violet-900/50 dark:text-violet-200',
ebook: 'bg-orange-100 text-orange-800 dark:bg-orange-900/50 dark:text-orange-200',
};
// Fork: a download that picked up work already under way (a torrent the client had, files
// already on disk). Sky blue, a hue neither the status badges nor the format pills use.
const REATTACHED_PILL_CLASSES = 'bg-sky-100 text-sky-800 dark:bg-sky-900/50 dark:text-sky-200';
type ActionIconName = 'cross' | 'check' | 'stop' | 'retry' | 'eye' | 'eye-off';
/** A React key for an action button: its kind plus the download or request it acts on. */
const actionKey = (action: ActivityCardAction): string => {
switch (action.kind) {
case 'download-remove':
@@ -80,6 +94,8 @@ const actionKey = (action: ActivityCardAction): string => {
return `${action.kind}-${action.requestId}-${action.record.id}`;
case 'request-reject':
case 'request-reopen':
case 'request-hide':
case 'request-unhide':
case 'request-cancel':
case 'request-dismiss':
return `${action.kind}-${action.requestId}`;
@@ -90,7 +106,7 @@ const actionKey = (action: ActivityCardAction): string => {
const actionUiConfig = (
action: ActivityCardAction,
): { title: string; className: string; icon: 'cross' | 'check' | 'stop' | 'retry' } => {
): { title: string; className: string; icon: ActionIconName } => {
switch (action.kind) {
case 'download-remove':
return {
@@ -135,6 +151,18 @@ const actionUiConfig = (
className: 'text-sky-600 dark:text-sky-400 hover:bg-sky-100 dark:hover:bg-sky-900/30',
icon: 'retry',
};
case 'request-hide':
return {
title: 'Hide from Rejected',
className: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700',
icon: 'eye-off',
};
case 'request-unhide':
return {
title: 'Show in Rejected again',
className: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700',
icon: 'eye',
};
case 'request-cancel':
return {
title: 'Cancel request',
@@ -156,7 +184,28 @@ const actionUiConfig = (
}
};
const ActionIcon = ({ icon }: { icon: 'cross' | 'check' | 'stop' | 'retry' }) => {
/** The small inline SVG for an action button. */
const ActionIcon = ({ icon }: { icon: ActionIconName }) => {
if (icon === 'eye' || icon === 'eye-off') {
return (
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12s3.75-6.75 9.75-6.75S21.75 12 21.75 12 18 18.75 12 18.75 2.25 12 2.25 12Z"
/>
<circle cx="12" cy="12" r="2.75" />
{icon === 'eye-off' && <path strokeLinecap="round" d="M4 4l16 16" />}
</svg>
);
}
if (icon === 'stop') {
return (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -494,6 +543,7 @@ export const ActivityCard = ({
onRequestReject,
onRequestRejectConfirm,
onRequestReopen,
onRequestSetHidden,
onRequestDismiss,
showRequestDetailsToggle = false,
isRequestDetailsOpen = false,
@@ -505,6 +555,7 @@ export const ActivityCard = ({
}: ActivityCardProps) => {
const model = useMemo(() => buildActivityCardModel(item, isAdmin), [item, isAdmin]);
const noteLine = model.noteLine;
const formatPill = item.formatPill;
const badgeRefs = useRef<Record<string, HTMLSpanElement | null>>({});
const titleLineRef = useRef<HTMLParagraphElement | null>(null);
const [badgeOverflow, setBadgeOverflow] = useState<Record<string, boolean>>({});
@@ -581,6 +632,7 @@ export const ActivityCard = ({
const reviewApproveHandler = onRequestReviewApprove || onRequestApprove;
const isDetailsExpanded = isRequestDetailsOpen || isRequestRejectOpen;
/** Call the handler the parent passed for this action; approve may open the details instead. */
const runAction = (action: ActivityCardAction) => {
switch (action.kind) {
case 'download-remove':
@@ -612,6 +664,12 @@ export const ActivityCard = ({
case 'request-reopen':
void onRequestReopen?.(action.requestId);
break;
case 'request-hide':
void onRequestSetHidden?.(action.requestId, true);
break;
case 'request-unhide':
void onRequestSetHidden?.(action.requestId, false);
break;
case 'request-cancel':
onRequestCancel?.(action.requestId);
break;
@@ -623,6 +681,7 @@ export const ActivityCard = ({
}
};
/** Whether the parent passed a handler for this action, so the button is worth showing. */
const hasActionHandler = (action: ActivityCardAction): boolean => {
switch (action.kind) {
case 'download-remove':
@@ -638,6 +697,9 @@ export const ActivityCard = ({
return Boolean(onRequestReject);
case 'request-reopen':
return Boolean(onRequestReopen);
case 'request-hide':
case 'request-unhide':
return Boolean(onRequestSetHidden);
case 'request-cancel':
return Boolean(onRequestCancel);
case 'request-dismiss':
@@ -801,9 +863,42 @@ export const ActivityCard = ({
</div>
</div>
<p className="mt-0.5 truncate text-[11px] leading-tight opacity-60" title={item.metaLine}>
{item.metaLine}
</p>
{formatPill ? (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-tight">
<span
className={`shrink-0 rounded px-1.5 py-px font-medium ${FORMAT_PILL_CLASSES[formatPill.kind]}`}
>
{formatPill.label}
</span>
{item.reattached && (
<span
className={`shrink-0 rounded px-1.5 py-px font-medium ${REATTACHED_PILL_CLASSES}`}
title="Picked up where it left off instead of starting again"
>
Reattached
</span>
)}
{item.metaLine && (
<span className="truncate opacity-60" title={item.metaLine}>
{item.metaLine}
</span>
)}
</div>
) : (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-tight">
{item.reattached && (
<span
className={`shrink-0 rounded px-1.5 py-px font-medium ${REATTACHED_PILL_CLASSES}`}
title="Picked up where it left off instead of starting again"
>
Reattached
</span>
)}
<p className="truncate opacity-60" title={item.metaLine}>
{item.metaLine}
</p>
</div>
)}
{noteLine && (
<p className="mt-0.5 truncate text-[11px] italic opacity-60" title={noteLine}>
@@ -3,9 +3,18 @@ import { useCallback, useMemo, useRef, useState, type WheelEvent } from 'react';
import { useTabIndicator } from '../../hooks/ui/useTabIndicator';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { useMediaQuery } from '../../hooks/useMediaQuery';
import { useRejectedRequests } from '../../hooks/useRejectedRequests';
import type { RequestRecord, StatusData } from '../../types';
import { Dropdown } from '../Dropdown';
import { ActivityCard } from './ActivityCard';
import {
getActivityCategory,
getCategoryHint,
getCategoryLabel,
getVisibleCategoryOrder,
type ActivityCategoryKey,
type ActivityTabKey,
} from './activityCategories';
import type { DownloadStatusKey } from './activityMappers';
import { downloadToActivityItem, linkedDownloadIdForRequest } from './activityMappers';
import type { ActivityItem } from './activityTypes';
@@ -65,89 +74,8 @@ const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [
'cancelled',
];
type ActivityCategoryKey = 'needs_review' | 'in_progress' | 'complete' | 'failed';
type ActivityTabKey = 'all' | 'downloads' | 'requests' | 'history';
const ALL_USERS_FILTER = '__all_users__';
const getCategoryLabel = (key: ActivityCategoryKey, isAdmin: boolean): string => {
if (key === 'needs_review') {
return isAdmin ? 'Needs Review' : 'Waiting';
}
if (key === 'in_progress') {
return 'In Progress';
}
if (key === 'complete') {
return 'Complete';
}
return 'Failed';
};
const getVisibleCategoryOrder = (tab: ActivityTabKey): ActivityCategoryKey[] => {
if (tab === 'downloads') {
return ['in_progress', 'complete', 'failed'];
}
if (tab === 'requests') {
return ['needs_review', 'in_progress', 'complete', 'failed'];
}
if (tab === 'history') {
return [];
}
return ['needs_review', 'in_progress', 'complete', 'failed'];
};
const getActivityCategory = (item: ActivityItem): ActivityCategoryKey => {
if (item.kind === 'download') {
if (
item.visualStatus === 'queued' ||
item.visualStatus === 'resolving' ||
item.visualStatus === 'locating' ||
item.visualStatus === 'downloading'
) {
return 'in_progress';
}
if (item.visualStatus === 'complete') {
return 'complete';
}
return 'failed';
}
const requestStatus = item.requestRecord?.status;
if (requestStatus === 'pending' || item.visualStatus === 'pending') {
return 'needs_review';
}
if (requestStatus === 'rejected' || requestStatus === 'cancelled') {
return 'failed';
}
const deliveryState = item.requestRecord?.delivery_state;
if (requestStatus === 'fulfilled' || item.visualStatus === 'fulfilled') {
if (
deliveryState === 'queued' ||
deliveryState === 'resolving' ||
deliveryState === 'locating' ||
deliveryState === 'downloading'
) {
return 'in_progress';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
// Legacy fulfilled requests often have unknown/none delivery state because the
// pre-refactor queue state was ephemeral. Treat as completed approval, not in-progress.
return 'complete';
}
if (deliveryState === 'complete') {
return 'complete';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
return 'in_progress';
};
const mergeRequestWithDownload = (
requestItem: ActivityItem,
downloadItem: ActivityItem,
@@ -249,26 +177,46 @@ export const ActivitySidebar = ({
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const dismissedKeySet = useMemo(() => new Set(dismissedItemKeys), [dismissedItemKeys]);
const { rejectedItems, rejectedLoading, loadRejected, setRejectedHidden } = useRejectedRequests();
const [showHiddenRejected, setShowHiddenRejected] = useState(false);
const hiddenRejectedCount = rejectedItems.filter((item) => item.hiddenInRejected).length;
const handleTabChange = useCallback(
(nextTab: ActivityTabKey) => {
if (nextTab === 'downloads') {
setRejectingRequest(null);
setReviewingRequestId(null);
}
if (nextTab === 'rejected') {
void loadRejected();
}
setActiveTab(nextTab);
onActiveTabChange?.(nextTab);
},
[onActiveTabChange],
[loadRejected, onActiveTabChange],
);
const isPinnedOpen = isOpen && isDesktop && isPinned;
const effectiveActiveTab = !showRequestsTab && activeTab === 'requests' ? 'all' : activeTab;
let effectiveActiveTab = activeTab;
if (!showRequestsTab && activeTab === 'requests') {
effectiveActiveTab = 'all';
} else if (!isAdmin && activeTab === 'rejected') {
effectiveActiveTab = 'all';
}
if (effectiveActiveTab !== activeTab) {
setActiveTab(effectiveActiveTab);
}
useEscapeKey(isOpen && !isPinnedOpen, onClose);
/** Reopen a rejected request, then reload the list whether or not the call succeeded. */
const handleRejectedReopen = useCallback(
(requestId: number) =>
Promise.resolve(onRequestReopen?.(requestId)).finally(() => {
void loadRejected();
}),
[loadRejected, onRequestReopen],
);
const downloadItems = useMemo(() => {
const items: ActivityItem[] = [];
@@ -401,6 +349,10 @@ export const ActivitySidebar = ({
});
} else if (effectiveActiveTab === 'history') {
baseVisibleItems = historyItems;
} else if (effectiveActiveTab === 'rejected') {
baseVisibleItems = showHiddenRejected
? rejectedItems
: rejectedItems.filter((item) => !item.hiddenInRejected);
}
const isHistoryInitialLoad = effectiveActiveTab === 'history' && !historyLoaded;
let emptyStateMessage = 'No activity';
@@ -411,6 +363,14 @@ export const ActivitySidebar = ({
historyLoading || isHistoryInitialLoad ? 'Loading history...' : 'No history';
} else if (effectiveActiveTab === 'downloads') {
emptyStateMessage = 'No downloads';
} else if (effectiveActiveTab === 'rejected') {
if (rejectedLoading) {
emptyStateMessage = 'Loading rejected requests...';
} else if (hiddenRejectedCount > 0 && !showHiddenRejected) {
emptyStateMessage = `No rejected requests shown (${hiddenRejectedCount} hidden)`;
} else {
emptyStateMessage = 'No rejected requests';
}
}
const availableUsers = useMemo(() => {
@@ -486,7 +446,7 @@ export const ActivitySidebar = ({
);
const groupedVisibleItems = useMemo(() => {
if (effectiveActiveTab === 'history') {
if (effectiveActiveTab === 'history' || effectiveActiveTab === 'rejected') {
return [];
}
@@ -508,6 +468,7 @@ export const ActivitySidebar = ({
.map((key) => ({
key,
label: getCategoryLabel(key, isAdmin),
hint: getCategoryHint(key, isAdmin),
items: (grouped.get(key) ?? []).toSorted((left, right) => right.timestamp - left.timestamp),
}))
.filter((group) => group.items.length > 0);
@@ -540,7 +501,9 @@ export const ActivitySidebar = ({
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{effectiveActiveTab === 'history' ? 'History' : 'Activity'}
{effectiveActiveTab === 'history' && 'History'}
{effectiveActiveTab === 'rejected' && 'Rejected'}
{effectiveActiveTab !== 'history' && effectiveActiveTab !== 'rejected' && 'Activity'}
</h2>
<button
type="button"
@@ -656,6 +619,36 @@ export const ActivitySidebar = ({
)}
</Dropdown>
)}
{isAdmin && (
<button
type="button"
onClick={() =>
handleTabChange(effectiveActiveTab === 'rejected' ? 'all' : 'rejected')
}
className={`hover-action relative inline-flex h-9 w-9 items-center justify-center rounded-full transition-colors ${
effectiveActiveTab === 'rejected' ? 'text-sky-600 dark:text-sky-400' : ''
}`}
title={
effectiveActiveTab === 'rejected' ? 'Back to activity' : 'Open rejected requests'
}
aria-label={
effectiveActiveTab === 'rejected' ? 'Back to activity' : 'Open rejected requests'
}
aria-pressed={effectiveActiveTab === 'rejected'}
>
<svg
className="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden="true"
>
<circle cx="12" cy="12" r="8.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6.2 6.2l11.6 11.6" />
</svg>
</button>
)}
<button
type="button"
onClick={() => handleTabChange(effectiveActiveTab === 'history' ? 'all' : 'history')}
@@ -703,7 +696,20 @@ export const ActivitySidebar = ({
</div>
</div>
{effectiveActiveTab !== 'history' && (
{effectiveActiveTab === 'rejected' && (
<div className="mt-2 flex items-center justify-end text-sm">
<label className="inline-flex cursor-pointer items-center gap-2 opacity-80">
<input
type="checkbox"
checked={showHiddenRejected}
onChange={(event) => setShowHiddenRejected(event.target.checked)}
/>
Show hidden ({hiddenRejectedCount})
</label>
</div>
)}
{effectiveActiveTab !== 'history' && effectiveActiveTab !== 'rejected' && (
<div className="-mx-4 mt-2 border-b border-(--border-muted) px-4">
<div className="relative flex gap-1">
{/* Sliding indicator */}
@@ -786,6 +792,22 @@ export const ActivitySidebar = ({
return <p className="mt-8 text-center text-sm opacity-70">{emptyStateMessage}</p>;
}
if (effectiveActiveTab === 'rejected') {
return (
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
{visibleItems.map((item) => (
<ActivityCard
key={item.id}
item={item}
isAdmin={isAdmin}
onRequestReopen={onRequestReopen ? handleRejectedReopen : undefined}
onRequestSetHidden={setRejectedHidden}
/>
))}
</div>
);
}
if (effectiveActiveTab === 'history') {
return (
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
@@ -844,6 +866,9 @@ export const ActivitySidebar = ({
</span>
</button>
)}
{group.hint && !collapsedGroups[group.key] && (
<p className="px-0 pb-1.5 text-[11px] leading-snug opacity-60">{group.hint}</p>
)}
{!collapsedGroups[group.key] && (
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
{group.items.map((item) => {
@@ -0,0 +1,35 @@
// Fork: scripts and the automatic passes act through the admin account and mark what they did with
// an "[auto] " admin note. The cards say "automatically" for those, so a person is not shown as
// the one who declined or approved something they never saw.
const AUTO_PREFIX = /^\s*\[auto\]\s*/i;
/** Whether an admin note starts with the "[auto] " marker that scripts and passes write. */
export const isAutomaticNote = (note: string | null | undefined): boolean =>
typeof note === 'string' && AUTO_PREFIX.test(note);
/** The note without its "[auto] " marker. */
export const stripAutomaticMarker = (note: string): string => note.replace(AUTO_PREFIX, '').trim();
interface RetryFields {
status?: string;
admin_note?: string | null;
last_failure_reason?: string | null;
}
/**
* A pending request the automation is retrying by itself (reopened after a failed or interrupted
* download), as opposed to one it searched and found nothing for. Nothing for a person to do.
*/
export const isRetryingAutomatically = (record: RetryFields | null | undefined): boolean => {
if (!record || record.status !== 'pending') {
return false;
}
const note = (record.admin_note ?? '').trim().toLowerCase();
const reason = (record.last_failure_reason ?? '').trim().toLowerCase();
return (
note.startsWith('[auto] retry') ||
reason.startsWith('retrying') ||
reason.startsWith('interrupted by a restart')
);
};
@@ -1,4 +1,9 @@
import type { RequestRecord } from '../../types';
import {
isAutomaticNote,
isRetryingAutomatically,
stripAutomaticMarker,
} from './activityAutomation';
import { isActiveDownloadStatus } from './activityStyles.js';
import type { ActivityItem, ActivityVisualStatus } from './activityTypes';
@@ -14,7 +19,13 @@ export type ActivityCardAction =
record: RequestRecord;
}
| {
kind: 'request-reject' | 'request-reopen' | 'request-cancel' | 'request-dismiss';
kind:
| 'request-reject'
| 'request-reopen'
| 'request-hide'
| 'request-unhide'
| 'request-cancel'
| 'request-dismiss';
requestId: number;
};
@@ -51,14 +62,15 @@ const toRequestVisualStatus = (status: RequestRecord['status']): ActivityVisualS
return 'cancelled';
};
const getPendingRequestText = (item: ActivityItem, isAdmin: boolean): string => {
if (!isAdmin) {
return 'Awaiting review';
}
const username = item.username?.trim() || item.requestRecord?.username?.trim();
return username ? `Needs review · ${username}` : 'Needs review';
/** The badge text for a pending request. The requester is named on the meta line, not here. */
const getPendingRequestText = (_item: ActivityItem, isAdmin: boolean): string => {
return isAdmin ? 'No match yet' : 'Awaiting review';
};
/**
* The status badge for a request card. A failure reason on a pending request shows as an error,
* an automatic retry says so, and a linked download in flight shows as approved.
*/
const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge => {
const requestVisualStatus = item.requestRecord
? toRequestVisualStatus(item.requestRecord.status)
@@ -79,6 +91,10 @@ const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadg
let text = item.statusLabel;
if (hasInFlightLinkedDownload) {
text = 'Approved';
} else if (isRetryingAutomatically(item.requestRecord)) {
// Fork: the automation reopened it after a failed download; the next pass searches again.
text = 'Retrying automatically';
visualStatus = 'resolving';
} else if (hasFailureReason && failureReason) {
text = failureReason;
} else if (requestVisualStatus === 'pending') {
@@ -116,6 +132,7 @@ const getDownloadBadge = (item: ActivityItem): ActivityCardBadge => {
};
};
/** The badges on a card: a download linked to a request shows both, once it is complete only its own. */
const buildBadges = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge[] => {
if (item.kind === 'download' && item.visualStatus === 'complete') {
return [getDownloadBadge(item)];
@@ -132,8 +149,57 @@ const buildBadges = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge[]
return [getDownloadBadge(item)];
};
const buildRequestNoteLine = (item: ActivityItem): string | undefined => {
/** A review stamp as "Mar 1", or undefined when it is missing or unreadable. */
const formatReviewDate = (value: string | null | undefined): string | undefined => {
if (!value) {
return undefined;
}
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) {
return undefined;
}
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(parsed);
};
/** An admin note for display: an automatic one loses its "[auto] " marker. */
const formatAdminNote = (note: string): string =>
isAutomaticNote(note) ? stripAutomaticMarker(note) : note;
/**
* Who declined a rejected request and when, with the note, for admins. The reviewer is only sent
* to admin views; a decline the automation made says so instead of naming the account.
*/
const buildDeclinedLine = (item: ActivityItem): string | undefined => {
const record = item.requestRecord;
if (!record || record.status !== 'rejected') {
return undefined;
}
// Fork: a decline the automation made says so, instead of naming the account it acted through.
const automatic = isAutomaticNote(item.adminNote);
let who = 'Declined';
if (automatic) {
who = 'Declined automatically';
} else if (record.reviewer_username) {
who = `Declined by ${record.reviewer_username}`;
}
const parts = [who];
const date = formatReviewDate(record.reviewed_at);
if (date) {
parts.push(date);
}
const line = parts.join(' · ');
return item.adminNote ? `${line}: "${formatAdminNote(item.adminNote)}"` : line;
};
/**
* The quoted note under a request card: the declined line for admins, the user's note while it is
* pending, or the admin note once it is decided. Undefined when there is nothing to say.
*/
const buildRequestNoteLine = (item: ActivityItem, isAdmin: boolean): string | undefined => {
const requestStatus = item.requestRecord?.status;
if (isAdmin && requestStatus === 'rejected') {
return buildDeclinedLine(item);
}
if (item.requestNote && (requestStatus === 'pending' || item.visualStatus === 'pending')) {
return `"${item.requestNote}"`;
}
@@ -144,11 +210,14 @@ const buildRequestNoteLine = (item: ActivityItem): string | undefined => {
item.visualStatus === 'rejected' ||
item.visualStatus === 'fulfilled')
) {
return `"${item.adminNote}"`;
return isAutomaticNote(item.adminNote)
? `Automatic: ${formatAdminNote(item.adminNote)}`
: `"${item.adminNote}"`;
}
return undefined;
};
/** The action buttons a card offers, by what the item is, its status and who is looking. */
const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[] => {
if (item.kind === 'download' && item.downloadBookId) {
const canRetry = item.downloadRetryAvailable === true;
@@ -215,10 +284,14 @@ const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[
}
if (item.visualStatus === 'rejected' && isAdmin) {
return [
{ kind: 'request-reopen', requestId: item.requestId },
{ kind: 'request-dismiss', requestId: item.requestId },
];
const actions: ActivityCardAction[] = [{ kind: 'request-reopen', requestId: item.requestId }];
if (item.hiddenInRejected === true) {
actions.push({ kind: 'request-unhide', requestId: item.requestId });
} else if (item.hiddenInRejected === false) {
actions.push({ kind: 'request-hide', requestId: item.requestId });
}
actions.push({ kind: 'request-dismiss', requestId: item.requestId });
return actions;
}
if (
@@ -236,7 +309,7 @@ const buildActions = (item: ActivityItem, isAdmin: boolean): ActivityCardAction[
export const buildActivityCardModel = (item: ActivityItem, isAdmin: boolean): ActivityCardModel => {
return {
badges: buildBadges(item, isAdmin),
noteLine: buildRequestNoteLine(item),
noteLine: buildRequestNoteLine(item, isAdmin),
actions: buildActions(item, isAdmin),
};
};
@@ -0,0 +1,107 @@
import { isRetryingAutomatically } from './activityAutomation';
import type { ActivityItem } from './activityTypes';
export type ActivityCategoryKey =
| 'needs_review'
| 'auto_retry'
| 'in_progress'
| 'complete'
| 'failed';
export type ActivityTabKey = 'all' | 'downloads' | 'requests' | 'history' | 'rejected';
/** The section header for a category; the no-match one reads differently for admins. */
export const getCategoryLabel = (key: ActivityCategoryKey, isAdmin: boolean): string => {
if (key === 'needs_review') {
// Fork: nothing here is waiting for a person; the automatic search found no match it trusts.
return isAdmin ? 'No Match Yet' : 'Waiting';
}
if (key === 'auto_retry') {
return 'Retrying Automatically';
}
if (key === 'in_progress') {
return 'In Progress';
}
if (key === 'complete') {
return 'Complete';
}
return 'Failed';
};
/** One line under a section header that says what, if anything, is expected of the reader. */
export const getCategoryHint = (key: ActivityCategoryKey, isAdmin: boolean): string | undefined => {
if (!isAdmin) {
return undefined;
}
if (key === 'needs_review') {
return 'Searched automatically on every sync. Choose a release yourself, decline, or leave it.';
}
if (key === 'auto_retry') {
return 'A download failed and another copy is being tried. Nothing to do.';
}
return undefined;
};
/** The categories a tab shows, in order. History and Rejected are flat lists. */
export const getVisibleCategoryOrder = (tab: ActivityTabKey): ActivityCategoryKey[] => {
if (tab === 'downloads') {
return ['in_progress', 'complete', 'failed'];
}
if (tab === 'history' || tab === 'rejected') {
return [];
}
return ['needs_review', 'auto_retry', 'in_progress', 'complete', 'failed'];
};
/** Which section an item belongs under, by whether it is a download or a request and its state. */
export const getActivityCategory = (item: ActivityItem): ActivityCategoryKey => {
if (item.kind === 'download') {
if (
item.visualStatus === 'queued' ||
item.visualStatus === 'resolving' ||
item.visualStatus === 'locating' ||
item.visualStatus === 'downloading'
) {
return 'in_progress';
}
if (item.visualStatus === 'complete') {
return 'complete';
}
return 'failed';
}
const requestStatus = item.requestRecord?.status;
if (requestStatus === 'pending' || item.visualStatus === 'pending') {
return isRetryingAutomatically(item.requestRecord) ? 'auto_retry' : 'needs_review';
}
if (requestStatus === 'rejected' || requestStatus === 'cancelled') {
return 'failed';
}
const deliveryState = item.requestRecord?.delivery_state;
if (requestStatus === 'fulfilled' || item.visualStatus === 'fulfilled') {
if (
deliveryState === 'queued' ||
deliveryState === 'resolving' ||
deliveryState === 'locating' ||
deliveryState === 'downloading'
) {
return 'in_progress';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
// Legacy fulfilled requests often have unknown/none delivery state because the
// pre-refactor queue state was ephemeral. Treat as completed approval, not in-progress.
return 'complete';
}
if (deliveryState === 'complete') {
return 'complete';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
return 'in_progress';
};
@@ -1,7 +1,8 @@
import type { Book, RequestRecord, StatusData } from '../../types';
import { getDownloadsCount } from '../../types';
import { STATUS_LABELS, isActiveDownloadStatus } from './activityStyles.js';
import type { ActivityItem, ActivityVisualStatus } from './activityTypes';
import { buildTimingText, parseBackendTimestamp } from './activityTiming';
import type { ActivityItem, FormatPill, ActivityVisualStatus } from './activityTypes';
export type DownloadStatusKey = Extract<
keyof StatusData,
@@ -82,6 +83,42 @@ const getDownloadProgress = (
return undefined;
};
// Fork: audio file formats, to colour a pill when the content type is not given.
const AUDIO_FILE_FORMATS = new Set([
'mp3',
'm4b',
'm4a',
'aac',
'flac',
'ogg',
'opus',
'wav',
'aax',
]);
const formatKind = (
contentType: string | undefined,
fileFormat: string | undefined,
): FormatPill['kind'] | undefined => {
const type = contentType?.toLowerCase();
if (type?.includes('audio')) return 'audiobook';
if (type) return 'ebook';
const format = fileFormat?.toLowerCase();
if (!format) return undefined;
return AUDIO_FILE_FORMATS.has(format) ? 'audiobook' : 'ebook';
};
const buildFormatPill = (
contentType: string | undefined,
fileFormat: string | undefined,
): FormatPill | undefined => {
const kind = formatKind(contentType, fileFormat);
if (!kind) return undefined;
const label = fileFormat?.toUpperCase() || (kind === 'audiobook' ? 'Audiobook' : 'Ebook');
return { label, kind };
};
/** A download from the status payload as a card item, with its format pill and timing text. */
export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey): ActivityItem => {
const visualStatus = statusKeyToVisualStatus(statusKey);
const requestId =
@@ -91,12 +128,26 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
const downloadsCount = getDownloadsCount(book);
const downloadsText =
downloadsCount != null ? `${downloadsCount.toLocaleString()} downloads` : undefined;
const formatPill = buildFormatPill(
toOptionalText(book.content_type),
toOptionalText(book.format),
);
// Fork: a finished download says how long it took and how long ago it ended.
const timingText =
statusKey === 'complete' || statusKey === 'error' || statusKey === 'cancelled'
? buildTimingText({
startedMs: toEpochMillis(book.added_time),
finishedMs: toEpochMillis(book.finished_at),
showDuration: statusKey === 'complete',
})
: undefined;
const metaLine = joinMetaParts([
toOptionalText(book.format)?.toUpperCase(),
formatPill ? undefined : toOptionalText(book.format)?.toUpperCase(),
toOptionalText(book.size),
toOptionalText(book.source_display_name) || toSourceLabel(book.source),
downloadsText,
toOptionalText(book.username),
timingText,
]);
const progress = getDownloadProgress(visualStatus, book.progress);
const statusDetail = toOptionalText(book.status_message);
@@ -110,6 +161,8 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey)
author: toText(book.author, 'Unknown author'),
preview: toOptionalText(book.preview),
metaLine,
formatPill,
reattached: book.reattached === true ? true : undefined,
statusLabel: STATUS_LABELS[visualStatus],
statusDetail,
progress,
@@ -138,24 +191,31 @@ const requestStatusToVisualStatus = (status: RequestRecord['status']): ActivityV
const buildRequestMetaLine = (
record: RequestRecord,
bookData: Record<string, unknown>,
_bookData: Record<string, unknown>,
releaseData: Record<string, unknown>,
viewerRole: 'user' | 'admin',
): string => {
// Fork: a request that has been delivered says how long ago. The migration backfills this
// stamp from SQLite's zone-free UTC created_at, so it is parsed as UTC, not local time.
const deliveredMs = parseBackendTimestamp(record.delivery_updated_at);
const delivered =
record.status === 'fulfilled' && record.delivery_state === 'complete' && deliveredMs > 0
? buildTimingText({ finishedMs: deliveredMs, showDuration: false, agoPrefix: 'delivered' })
: undefined;
if (record.request_level === 'book') {
const contentType = toOptionalText(record.content_type || bookData.content_type)?.toLowerCase();
const requestTypeLabel = contentType === 'audiobook' ? 'Audiobook request' : 'Book request';
// Fork: the card shows the format as a coloured pill, so the line only names the requester.
const username = viewerRole === 'admin' ? toOptionalText(record.username) : undefined;
return joinMetaParts([requestTypeLabel, username]);
return joinMetaParts([username, delivered]);
}
const format = toOptionalText(releaseData.format)?.toUpperCase();
// Fork: the release format is shown as the card's pill.
const size = toOptionalText(releaseData.size);
const source = toSourceLabel(releaseData.source || record.source_hint);
const username = viewerRole === 'admin' ? toOptionalText(record.username) : undefined;
const line = joinMetaParts([format, size, source, username]);
return line || joinMetaParts(['Release request', username]);
const line = joinMetaParts([size, source, username, delivered]);
return line || joinMetaParts(['Release request', username, delivered]);
};
export const requestToActivityItem = (
@@ -178,6 +238,10 @@ export const requestToActivityItem = (
author: toText(bookData.author ?? releaseData.author, 'Unknown author'),
preview: toOptionalText(bookData.preview) || toOptionalText(releaseData.preview),
metaLine: buildRequestMetaLine(record, bookData, releaseData, viewerRole),
formatPill: buildFormatPill(
toOptionalText(record.content_type || bookData.content_type),
record.request_level === 'release' ? toOptionalText(releaseData.format) : undefined,
),
statusLabel: STATUS_LABELS[visualStatus],
adminNote: toOptionalText(record.admin_note),
timestamp,
@@ -0,0 +1,99 @@
// Fork: how long a finished download took and how long ago it ended, as short text for the end of
// a card's meta line ("took 4m · 2h ago"). Text only, so the cards stay as quiet as they are.
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// "YYYY-MM-DD HH:MM:SS" or "YYYY-MM-DDTHH:MM:SS" with no zone: SQLite's CURRENT_TIMESTAMP shape.
const ZONE_FREE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/;
/**
* Epoch ms for a backend timestamp, or 0 when it is missing or unreadable. A value with no
* zone is UTC (SQLite's CURRENT_TIMESTAMP writes those); an explicit zone or offset is kept.
*/
export const parseBackendTimestamp = (value: string | null | undefined): number => {
if (!value) {
return 0;
}
const text = value.trim();
const parsed = Date.parse(ZONE_FREE_TIMESTAMP.test(text) ? `${text.replace(' ', 'T')}Z` : text);
return Number.isFinite(parsed) ? parsed : 0;
};
/** "45s", "4m", "1h 20m", "3d": the two most useful units, never more. */
export const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms < 0) {
return '';
}
// Round first, then pick the unit, so 59.6s is "1m" and not "60s".
const seconds = Math.max(Math.round(ms / SECOND), 1);
if (seconds < 60) {
return `${seconds}s`;
}
const totalMinutes = Math.round(ms / MINUTE);
if (totalMinutes < 60) {
return `${totalMinutes}m`;
}
if (totalMinutes < 24 * 60) {
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
return `${Math.max(Math.floor(ms / DAY), 1)}d`;
};
/** "just now", "12m ago", "3h ago", "2d ago". */
export const formatAgo = (ms: number): string => {
if (!Number.isFinite(ms) || ms < 0) {
return '';
}
if (ms < MINUTE) {
return 'just now';
}
if (ms < HOUR) {
return `${Math.floor(ms / MINUTE)}m ago`;
}
if (ms < DAY) {
return `${Math.floor(ms / HOUR)}h ago`;
}
return `${Math.floor(ms / DAY)}d ago`;
};
interface TimingInput {
/** When it started, epoch ms. 0 or missing when unknown. */
startedMs?: number;
/** When it ended, epoch ms. 0 or missing when unknown. */
finishedMs?: number;
/** Say how long it took (a completed download); an error or cancel only says how long ago. */
showDuration: boolean;
/** Lead the ago text, e.g. "delivered" for a request. */
agoPrefix?: string;
now?: number;
}
/** The text for the end of the meta line, or undefined when nothing is known. */
export const buildTimingText = ({
startedMs = 0,
finishedMs = 0,
showDuration,
agoPrefix,
now = Date.now(),
}: TimingInput): string | undefined => {
if (finishedMs <= 0) {
return undefined;
}
const parts: string[] = [];
if (showDuration && startedMs > 0 && finishedMs >= startedMs) {
const took = formatDuration(finishedMs - startedMs);
if (took) {
parts.push(`took ${took}`);
}
}
const ago = formatAgo(now - finishedMs);
if (ago) {
parts.push(agoPrefix ? `${agoPrefix} ${ago}` : ago);
}
return parts.length > 0 ? parts.join(' · ') : undefined;
};
@@ -14,6 +14,11 @@ export type ActivityVisualStatus =
| 'fulfilled'
| 'rejected';
export interface FormatPill {
label: string;
kind: 'audiobook' | 'ebook';
}
export interface ActivityItem {
id: string;
kind: ActivityKind;
@@ -44,4 +49,10 @@ export interface ActivityItem {
requestLevel?: 'book' | 'release';
requestNote?: string;
requestRecord?: RequestRecord;
// Fork: format shown as a pill coloured by kind, e.g. EPUB in the ebook colour.
formatPill?: FormatPill;
// Fork: the download picked up work that was already under way (shown as a pill).
reattached?: boolean;
// Fork Rejected view only: whether the admin hid this rejected request there.
hiddenInRejected?: boolean;
}
+2 -2
View File
@@ -280,7 +280,7 @@ interface UseActivityResult {
prefetchActivityHistory: () => void;
refreshActivitySnapshot: () => Promise<void>;
refreshHistoryIfLoaded: () => void;
handleActivityTabChange: (tab: 'all' | 'downloads' | 'requests' | 'history') => void;
handleActivityTabChange: (tab: 'all' | 'downloads' | 'requests' | 'history' | 'rejected') => void;
resetActivity: () => void;
handleActivityHistoryLoadMore: () => void;
handleRequestDismiss: (requestId: number) => void;
@@ -366,7 +366,7 @@ export const useActivity = ({
}, [isAuthenticated, resetActivityHistory]);
const handleActivityTabChange = useCallback(
(tab: 'all' | 'downloads' | 'requests' | 'history') => {
(tab: 'all' | 'downloads' | 'requests' | 'history' | 'rejected') => {
if (tab !== 'history' || activityHistoryLoaded || activityHistoryLoading) {
return;
}
@@ -0,0 +1,78 @@
import { useCallback, useRef, useState } from 'react';
import { requestToActivityItem } from '../components/activity/activityMappers';
import type { ActivityItem } from '../components/activity/activityTypes';
import {
listHiddenRejectedRequestIds,
listRejectedAdminRequests,
setRejectedRequestHidden,
} from '../services/api';
interface UseRejectedRequestsResult {
rejectedItems: ActivityItem[];
rejectedLoading: boolean;
loadRejected: () => Promise<void>;
setRejectedHidden: (requestId: number, hidden: boolean) => Promise<void>;
}
/**
* Rejected requests for the admin Rejected view. Unlike the activity snapshot this includes
* requests that were cleared from the list, which is where most of them are. Each item says
* whether the admin hid it from this view (its own state, not the activity list's clear).
* Loaded from the events that change it, like History.
*/
export const useRejectedRequests = (): UseRejectedRequestsResult => {
const [rejectedItems, setRejectedItems] = useState<ActivityItem[]>([]);
const [rejectedLoading, setRejectedLoading] = useState(false);
const latestLoad = useRef(0);
/** Fetch the rejected requests and hidden ids; a stale load is dropped and an error empties the list. */
const loadRejected = useCallback(async () => {
latestLoad.current += 1;
const loadId = latestLoad.current;
setRejectedLoading(true);
try {
const [records, hiddenIds] = await Promise.all([
listRejectedAdminRequests(),
listHiddenRejectedRequestIds(),
]);
if (loadId !== latestLoad.current) {
return;
}
const hidden = new Set(hiddenIds);
const items = records.map((record) => {
const item = requestToActivityItem(record, 'admin');
item.hiddenInRejected = hidden.has(record.id);
return item;
});
items.sort((a, b) => b.timestamp - a.timestamp);
setRejectedItems(items);
} catch (error) {
console.error('Loading rejected requests failed:', error);
if (loadId === latestLoad.current) {
setRejectedItems([]);
}
} finally {
if (loadId === latestLoad.current) {
setRejectedLoading(false);
}
}
}, []);
/** Hide or show one request in the view; the item is left as it was when the call fails. */
const setRejectedHidden = useCallback(async (requestId: number, hidden: boolean) => {
try {
await setRejectedRequestHidden(requestId, hidden);
} catch (error) {
console.error('Changing hidden state failed:', error);
return;
}
setRejectedItems((current) =>
current.map((item) =>
item.requestId === requestId ? Object.assign({}, item, { hiddenInRejected: hidden }) : item,
),
);
}, []);
return { rejectedItems, rejectedLoading, loadRejected, setRejectedHidden };
};
+19
View File
@@ -709,6 +709,25 @@ export const rejectAdminRequest = async (
});
};
export const listRejectedAdminRequests = async (): Promise<RequestRecord[]> => {
return fetchJSON<RequestRecord[]>(`${API.adminRequests}?status=rejected`);
};
/** Ids of the rejected requests hidden from the admin Rejected view; empty when the field is missing. */
export const listHiddenRejectedRequestIds = async (): Promise<number[]> => {
const response = await fetchJSON<{ request_ids: number[] }>(
`${API.adminRequests}/rejected-hidden`,
);
return Array.isArray(response.request_ids) ? response.request_ids : [];
};
export const setRejectedRequestHidden = async (id: number, hidden: boolean): Promise<void> => {
await fetchJSON(`${API.adminRequests}/${encodeURIComponent(String(id))}/rejected-hidden`, {
method: 'POST',
body: JSON.stringify({ hidden }),
});
};
export const reopenAdminRequest = async (
id: number,
body: RejectAdminRequestBody = {},
@@ -16,6 +16,18 @@ const makeItem = (overrides: Partial<ActivityItem> = {}): ActivityItem => ({
...overrides,
});
const rejectedActionKinds = (hiddenInRejected?: boolean) =>
buildActivityCardModel(
makeItem({
kind: 'request',
visualStatus: 'rejected',
requestId: 47,
downloadBookId: undefined,
hiddenInRejected,
}),
true,
).actions.map((action) => action.kind);
describe('activityCardModel', () => {
it('shows ownership in badge text for admin pending requests', () => {
const model = buildActivityCardModel(
@@ -30,7 +42,7 @@ describe('activityCardModel', () => {
);
expect(model.badges.length).toBe(1);
expect(model.badges[0]?.text).toBe('Needs review · testuser');
expect(model.badges[0]?.text).toBe('No match yet');
});
it('keeps pending label for requester-side pending requests', () => {
@@ -273,4 +285,152 @@ describe('activityCardModel', () => {
expect(model.actions.map((action) => action.kind)).toEqual(['request-dismiss']);
});
it('tells an admin who declined a request and when', () => {
const record = {
id: 45,
user_id: 7,
status: 'rejected' as const,
source_hint: null,
content_type: 'ebook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'My Dog Thinks It Is a Cat' },
release_data: null,
note: null,
admin_note: 'already in the library',
reviewed_by: 1,
reviewed_at: '2026-09-22T05:31:12Z',
created_at: '2026-09-20T03:37:29Z',
updated_at: '2026-09-22T05:31:12Z',
username: 'reader',
reviewer_username: 'admin@example.com',
};
const item = makeItem({
kind: 'request',
visualStatus: 'rejected',
requestId: 45,
downloadBookId: undefined,
adminNote: record.admin_note,
requestRecord: record,
});
const adminLine = buildActivityCardModel(item, true).noteLine;
expect(adminLine).toMatch(/^Declined by admin@example\.com · .+: "already in the library"$/);
const requesterLine = buildActivityCardModel(item, false).noteLine;
expect(requesterLine).toBe('"already in the library"');
});
it("says a decline the automation made was automatic, not a person's", () => {
const record = {
id: 46,
user_id: 7,
status: 'rejected' as const,
source_hint: null,
content_type: 'audiobook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'Sauron Defeated' },
release_data: null,
note: null,
admin_note: '[auto] No audiobook found on Audible (US/UK) or our sources, 2026-10-11.',
reviewed_by: 1,
reviewed_at: '2026-10-11T22:40:00Z',
created_at: '2026-10-01T03:37:29Z',
updated_at: '2026-10-11T22:40:00Z',
username: 'reader',
reviewer_username: 'rob@martin-up.com',
};
const item = makeItem({
kind: 'request',
visualStatus: 'rejected',
requestId: 46,
downloadBookId: undefined,
adminNote: record.admin_note,
requestRecord: record,
});
const adminLine = buildActivityCardModel(item, true).noteLine;
expect(adminLine).toMatch(/^Declined automatically · .+: "No audiobook found on Audible/);
expect(adminLine).not.toContain('rob@martin-up.com');
expect(adminLine).not.toContain('[auto]');
expect(buildActivityCardModel(item, false).noteLine).toBe(
'Automatic: No audiobook found on Audible (US/UK) or our sources, 2026-10-11.',
);
});
it('says an approval the automation made was automatic', () => {
const item = makeItem({
kind: 'request',
visualStatus: 'fulfilled',
requestId: 47,
downloadBookId: undefined,
adminNote: '[auto] Already in the library.',
requestRecord: {
id: 47,
user_id: 7,
status: 'fulfilled' as const,
source_hint: null,
content_type: 'ebook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'X' },
release_data: null,
note: null,
admin_note: '[auto] Already in the library.',
reviewed_by: 1,
reviewed_at: '2026-10-11T22:40:00Z',
created_at: '2026-10-01T03:37:29Z',
updated_at: '2026-10-11T22:40:00Z',
username: 'reader',
},
});
expect(buildActivityCardModel(item, true).noteLine).toBe('Automatic: Already in the library.');
});
it('says a pending request the automation reopened is retrying automatically', () => {
const record = {
id: 48,
user_id: 7,
status: 'pending' as const,
source_hint: null,
content_type: 'audiobook' as const,
request_level: 'book' as const,
policy_mode: 'request_book' as const,
book_data: { title: 'The Big Bad Wolf' },
release_data: null,
note: null,
admin_note: '[auto] retry batch 5 of 7 after the cancelled-download reopen',
reviewed_by: null,
reviewed_at: null,
created_at: '2026-10-01T03:37:29Z',
updated_at: '2026-10-16T22:40:00Z',
username: 'reader',
last_failure_reason: 'Peer transfer failed',
};
const model = buildActivityCardModel(
makeItem({ kind: 'request', visualStatus: 'pending', requestId: 48, requestRecord: record }),
true,
);
expect(model.badges[0]?.text).toBe('Retrying automatically');
expect(model.badges[0]?.visualStatus).not.toBe('error');
});
it('offers hide or unhide on rejected cards only in the Rejected view', () => {
expect(rejectedActionKinds()).toEqual(['request-reopen', 'request-dismiss']);
expect(rejectedActionKinds(false)).toEqual([
'request-reopen',
'request-hide',
'request-dismiss',
]);
expect(rejectedActionKinds(true)).toEqual([
'request-reopen',
'request-unhide',
'request-dismiss',
]);
});
});
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import {
isAutomaticNote,
isRetryingAutomatically,
stripAutomaticMarker,
} from '../components/activity/activityAutomation';
import {
getActivityCategory,
getCategoryHint,
getCategoryLabel,
getVisibleCategoryOrder,
} from '../components/activity/activityCategories';
import type { ActivityItem } from '../components/activity/activityTypes';
import type { RequestRecord } from '../types';
const pendingItem = (extra: Partial<RequestRecord>): ActivityItem => ({
id: 'request-1',
kind: 'request',
visualStatus: 'pending',
title: 'T',
author: 'A',
metaLine: '',
statusLabel: 'Pending',
timestamp: 0,
requestRecord: {
id: 1,
user_id: 7,
status: 'pending',
source_hint: null,
content_type: 'audiobook',
request_level: 'book',
policy_mode: 'request_book',
book_data: { title: 'T' },
release_data: null,
note: null,
admin_note: null,
reviewed_by: null,
reviewed_at: null,
created_at: '2026-10-01T00:00:00Z',
updated_at: '2026-10-01T00:00:00Z',
username: 'reader',
...extra,
},
});
describe('automatic notes', () => {
it('recognises and strips the marker', () => {
expect(isAutomaticNote('[auto] Already in the library.')).toBe(true);
expect(isAutomaticNote(' [AUTO] x')).toBe(true);
expect(isAutomaticNote('a person wrote this')).toBe(false);
expect(isAutomaticNote(null)).toBe(false);
expect(stripAutomaticMarker('[auto] Already in the library.')).toBe('Already in the library.');
});
});
describe('isRetryingAutomatically', () => {
it('is true for a pending request the automation reopened', () => {
expect(isRetryingAutomatically({ status: 'pending', admin_note: '[auto] retry batch 5' })).toBe(
true,
);
expect(
isRetryingAutomatically({
status: 'pending',
last_failure_reason: 'Retrying after a 20-minute cooldown (failed)',
}),
).toBe(true);
expect(
isRetryingAutomatically({
status: 'pending',
last_failure_reason: 'Interrupted by a restart',
}),
).toBe(true);
});
it('is false when nothing was reopened, or the request is not pending', () => {
expect(isRetryingAutomatically({ status: 'pending' })).toBe(false);
expect(
isRetryingAutomatically({
status: 'pending',
last_failure_reason: 'All download sources failed',
}),
).toBe(false);
expect(isRetryingAutomatically({ status: 'rejected', admin_note: '[auto] retry' })).toBe(false);
expect(isRetryingAutomatically(null)).toBe(false);
});
});
describe('request categories', () => {
it('puts a reopened request under Retrying Automatically', () => {
expect(getActivityCategory(pendingItem({ admin_note: '[auto] retry batch 5' }))).toBe(
'auto_retry',
);
});
it('keeps a request nothing matched under No Match Yet', () => {
expect(getActivityCategory(pendingItem({}))).toBe('needs_review');
expect(
getActivityCategory(pendingItem({ last_failure_reason: 'All download sources failed' })),
).toBe('needs_review');
});
it('labels the sections for what they mean', () => {
expect(getCategoryLabel('needs_review', true)).toBe('No Match Yet');
expect(getCategoryLabel('needs_review', false)).toBe('Waiting');
expect(getCategoryLabel('auto_retry', true)).toBe('Retrying Automatically');
});
it('says what is expected of an admin, and nothing to anyone else', () => {
expect(getCategoryHint('needs_review', true)).toContain('Searched automatically');
expect(getCategoryHint('auto_retry', true)).toContain('Nothing to do');
expect(getCategoryHint('needs_review', false)).toBeUndefined();
expect(getCategoryHint('complete', true)).toBeUndefined();
});
it('shows the retrying section on the requests and all tabs but not under downloads', () => {
expect(getVisibleCategoryOrder('requests')).toContain('auto_retry');
expect(getVisibleCategoryOrder('all')).toContain('auto_retry');
expect(getVisibleCategoryOrder('downloads')).not.toContain('auto_retry');
expect(getVisibleCategoryOrder('history')).toEqual([]);
});
});
+129 -6
View File
@@ -85,7 +85,8 @@ describe('activityMappers.downloadToActivityItem', () => {
expect(item.kind).toBe('download');
expect(item.visualStatus).toBe('queued');
expect(item.statusLabel).toBe('Queued');
expect(item.metaLine).toBe('EPUB · 3 MB · Direct Download · alice');
expect(item.metaLine).toBe('3 MB · Direct Download · alice');
expect(item.formatPill).toEqual({ label: 'EPUB', kind: 'ebook' });
expect(item.progress).toBe(5);
expect(item.progressAnimated).toBe(true);
expect(item.timestamp).toBe(123);
@@ -115,7 +116,8 @@ describe('activityMappers.downloadToActivityItem', () => {
it('omits empty meta parts cleanly', () => {
const item = downloadToActivityItem(makeBook({ format: 'epub', size: undefined }), 'error');
expect(item.metaLine).toBe('EPUB');
expect(item.metaLine).toBe('');
expect(item.formatPill).toEqual({ label: 'EPUB', kind: 'ebook' });
});
});
@@ -139,7 +141,8 @@ describe('activityMappers.requestToActivityItem', () => {
expect(item.kind).toBe('request');
expect(item.visualStatus).toBe('pending');
expect(item.metaLine).toBe('EPUB · 2 MB · Prowlarr · alice');
expect(item.metaLine).toBe('2 MB · Prowlarr · alice');
expect(item.formatPill).toEqual({ label: 'EPUB', kind: 'ebook' });
expect(item.requestId).toBe(42);
expect(item.requestLevel).toBe('release');
expect(item.requestNote).toBe('please add this');
@@ -157,7 +160,7 @@ describe('activityMappers.requestToActivityItem', () => {
'user',
);
expect(item.metaLine).toBe('Book request');
expect(item.metaLine).toBe('');
});
it('maps audiobook book-level request with audiobook label', () => {
@@ -171,7 +174,7 @@ describe('activityMappers.requestToActivityItem', () => {
'admin',
);
expect(item.metaLine).toBe('Audiobook request · alice');
expect(item.metaLine).toBe('alice');
});
it('maps rejected requests with admin note', () => {
@@ -189,7 +192,7 @@ describe('activityMappers.requestToActivityItem', () => {
it('does not append username to meta line for user viewer role', () => {
const item = requestToActivityItem(makeRequest(), 'user');
expect(item.metaLine).toBe('EPUB · 2 MB · Prowlarr');
expect(item.metaLine).toBe('2 MB · Prowlarr');
});
});
@@ -221,3 +224,123 @@ describe('activityMappers.linkedDownloadIdForRequest', () => {
expect(linkedDownloadIdForRequest(item, new Map([[42, 'dl_abc']]))).toBeNull();
});
});
describe('activityMappers format pill', () => {
it('colours an audio file as an audiobook even without a content type', () => {
const item = downloadToActivityItem(
{ id: 'a1', title: 'Young Zaphod', author: 'Douglas Adams', format: 'm4b' },
'complete',
);
expect(item.formatPill).toEqual({ label: 'M4B', kind: 'audiobook' });
});
it('uses the content type over the file format', () => {
const item = downloadToActivityItem(
{
id: 'a2',
title: 'Young Zaphod',
author: 'Douglas Adams',
format: 'mp3',
content_type: 'audiobook',
},
'complete',
);
expect(item.formatPill).toEqual({ label: 'MP3', kind: 'audiobook' });
});
it('labels a book-level request by its type', () => {
const item = requestToActivityItem(
makeRequest({ request_level: 'book', content_type: 'audiobook', release_data: null }),
'admin',
);
expect(item.formatPill).toEqual({ label: 'Audiobook', kind: 'audiobook' });
});
});
describe('activityMappers reattached pill', () => {
it('carries the flag for a download that picked up work already under way', () => {
const item = downloadToActivityItem(
{ id: 'r1', title: 'Cross Down', author: 'James Patterson', reattached: true },
'downloading',
);
expect(item.reattached).toBe(true);
});
it('leaves it off for an ordinary download', () => {
const item = downloadToActivityItem(
{ id: 'r2', title: 'Cross Down', author: 'James Patterson' },
'downloading',
);
expect(item.reattached).toBeUndefined();
});
});
describe('activityMappers timing', () => {
const nowSeconds = Math.floor(Date.now() / 1000);
it('adds how long a completed download took and how long ago to the meta line', () => {
const item = downloadToActivityItem(
{
id: 't1',
title: 'Cross Down',
author: 'James Patterson',
format: 'm4b',
added_time: nowSeconds - 2 * 3600 - 240,
finished_at: nowSeconds - 2 * 3600,
},
'complete',
);
expect(item.metaLine).toContain('took 4m');
expect(item.metaLine).toContain('2h ago');
});
it('only says how long ago for a failed download', () => {
const item = downloadToActivityItem(
{
id: 't2',
title: 'X',
author: 'Y',
added_time: nowSeconds - 600,
finished_at: nowSeconds - 300,
},
'error',
);
expect(item.metaLine).toContain('5m ago');
expect(item.metaLine).not.toContain('took');
});
it('adds nothing for a download that is still running', () => {
const item = downloadToActivityItem(
{ id: 't3', title: 'X', author: 'Y', added_time: nowSeconds - 600, finished_at: null },
'downloading',
);
expect(item.metaLine).not.toContain('ago');
});
it('says a request was delivered how long ago', () => {
const item = requestToActivityItem(
makeRequest({
status: 'fulfilled',
delivery_state: 'complete',
delivery_updated_at: new Date(Date.now() - 3 * 3600 * 1000).toISOString(),
}),
'admin',
);
expect(item.metaLine).toContain('delivered 3h ago');
});
it('reads a migrated zone-free delivery stamp as UTC', () => {
const delivered = new Date(Date.now() - 3 * 3600 * 1000);
const stamp = delivered.toISOString().slice(0, 19).replace('T', ' ');
const item = requestToActivityItem(
makeRequest({ status: 'fulfilled', delivery_state: 'complete', delivery_updated_at: stamp }),
'admin',
);
expect(item.metaLine).toContain('delivered 3h ago');
});
it('says nothing about delivery for a request that is still waiting', () => {
const item = requestToActivityItem(makeRequest({ status: 'pending' }), 'admin');
expect(item.metaLine).not.toContain('delivered');
});
});
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import {
buildTimingText,
formatAgo,
formatDuration,
parseBackendTimestamp,
} from '../components/activity/activityTiming';
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
describe('formatDuration', () => {
it('uses the two most useful units', () => {
expect(formatDuration(500)).toBe('1s');
expect(formatDuration(45 * SECOND)).toBe('45s');
expect(formatDuration(4 * MINUTE)).toBe('4m');
expect(formatDuration(HOUR + 20 * MINUTE)).toBe('1h 20m');
expect(formatDuration(3 * HOUR)).toBe('3h');
expect(formatDuration(3 * DAY + 5 * HOUR)).toBe('3d');
});
it('rounds before picking the unit, so it never says 60 of anything', () => {
expect(formatDuration(59.6 * SECOND)).toBe('1m');
expect(formatDuration(59.6 * MINUTE)).toBe('1h');
expect(formatDuration(HOUR + 59 * MINUTE + 45 * SECOND)).toBe('2h');
expect(formatDuration(23 * HOUR + 59 * MINUTE + 45 * SECOND)).toBe('1d');
});
it('says nothing for a value that makes no sense', () => {
expect(formatDuration(-1)).toBe('');
expect(formatDuration(Number.NaN)).toBe('');
});
});
describe('formatAgo', () => {
it('reads as a relative time', () => {
expect(formatAgo(10 * SECOND)).toBe('just now');
expect(formatAgo(12 * MINUTE)).toBe('12m ago');
expect(formatAgo(3 * HOUR + 40 * MINUTE)).toBe('3h ago');
expect(formatAgo(2 * DAY)).toBe('2d ago');
});
it('says nothing for a time in the future', () => {
expect(formatAgo(-5 * MINUTE)).toBe('');
});
});
describe('buildTimingText', () => {
const now = 1_000_000_000_000;
it('says how long a completed download took and how long ago it ended', () => {
const text = buildTimingText({
startedMs: now - 2 * HOUR - 4 * MINUTE,
finishedMs: now - 2 * HOUR,
showDuration: true,
now,
});
expect(text).toBe('took 4m · 2h ago');
});
it('only says how long ago when the duration is not wanted', () => {
expect(
buildTimingText({
startedMs: now - 3 * HOUR,
finishedMs: now - HOUR,
showDuration: false,
now,
}),
).toBe('1h ago');
});
it('leads the ago text with a prefix when one is given', () => {
expect(
buildTimingText({
finishedMs: now - 3 * HOUR,
showDuration: false,
agoPrefix: 'delivered',
now,
}),
).toBe('delivered 3h ago');
});
it('skips the duration when the start is unknown or after the end', () => {
expect(buildTimingText({ finishedMs: now - MINUTE * 5, showDuration: true, now })).toBe(
'5m ago',
);
expect(
buildTimingText({ startedMs: now, finishedMs: now - MINUTE * 5, showDuration: true, now }),
).toBe('5m ago');
});
it('says nothing without a finish time', () => {
expect(buildTimingText({ startedMs: now - HOUR, showDuration: true, now })).toBeUndefined();
expect(buildTimingText({ finishedMs: 0, showDuration: true, now })).toBeUndefined();
});
});
describe('parseBackendTimestamp', () => {
it('reads a zone-free SQLite stamp as UTC', () => {
expect(parseBackendTimestamp('2026-03-01 12:00:00')).toBe(Date.UTC(2026, 2, 1, 12));
expect(parseBackendTimestamp('2026-03-01T12:00:00')).toBe(Date.UTC(2026, 2, 1, 12));
});
it('keeps an explicit zone or offset', () => {
expect(parseBackendTimestamp('2026-03-01T12:00:00Z')).toBe(Date.UTC(2026, 2, 1, 12));
expect(parseBackendTimestamp('2026-03-01T12:00:00+02:00')).toBe(Date.UTC(2026, 2, 1, 10));
});
it('is 0 for nothing or nonsense', () => {
expect(parseBackendTimestamp(undefined)).toBe(0);
expect(parseBackendTimestamp(null)).toBe(0);
expect(parseBackendTimestamp('')).toBe(0);
expect(parseBackendTimestamp('yesterday')).toBe(0);
});
});
+3
View File
@@ -57,6 +57,8 @@ export interface Book {
titles_by_language?: Record<string, string>;
username?: string;
retry_available?: boolean;
finished_at?: number | null; // When a finished download ended, epoch seconds
reattached?: boolean; // Picked up work already under way, e.g. a torrent the client had
downloads?: number;
extra?: Record<string, unknown>;
}
@@ -265,6 +267,7 @@ export interface RequestRecord {
created_at: string;
updated_at: string;
username?: string;
reviewer_username?: string;
}
export interface QueuedDownloadResult {