Collapse filing sync notifications into one batch surface
This commit is contained in:
@@ -8,7 +8,14 @@ import {
|
||||
listRecentTasks,
|
||||
updateTaskNotificationState
|
||||
} from '@/lib/api';
|
||||
import type { Task, TaskStatus } from '@/lib/types';
|
||||
import {
|
||||
buildNotificationEntries,
|
||||
EMPTY_FILING_SYNC_BATCH_STATE,
|
||||
isFilingSyncEntry,
|
||||
notificationEntrySignature,
|
||||
type FilingSyncBatchState
|
||||
} from '@/lib/task-notification-entries';
|
||||
import type { Task, TaskNotificationEntry, TaskStatus } from '@/lib/types';
|
||||
|
||||
const ACTIVE_STATUSES: TaskStatus[] = ['queued', 'running'];
|
||||
const TERMINAL_STATUSES: TaskStatus[] = ['completed', 'failed'];
|
||||
@@ -17,19 +24,101 @@ function isTerminalTask(task: Task) {
|
||||
return TERMINAL_STATUSES.includes(task.status);
|
||||
}
|
||||
|
||||
function taskSignature(task: Task) {
|
||||
return JSON.stringify({
|
||||
status: task.status,
|
||||
stage: task.stage,
|
||||
stageDetail: task.stage_detail,
|
||||
stageContext: task.stage_context,
|
||||
error: task.error,
|
||||
result: isTerminalTask(task) ? task.result : null
|
||||
});
|
||||
function isTerminalEntry(entry: TaskNotificationEntry) {
|
||||
return TERMINAL_STATUSES.includes(entry.status);
|
||||
}
|
||||
|
||||
function taskProgressLabel(task: Task) {
|
||||
const progress = task.notification.progress;
|
||||
function shouldNotifyEntry(entry: TaskNotificationEntry) {
|
||||
return entry.notificationSilencedAt === null;
|
||||
}
|
||||
|
||||
function isUnreadEntry(entry: TaskNotificationEntry) {
|
||||
return entry.notificationReadAt === null;
|
||||
}
|
||||
|
||||
function sortTasksByUpdated(tasks: Task[]) {
|
||||
return [...tasks].sort((left, right) => (
|
||||
new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime()
|
||||
));
|
||||
}
|
||||
|
||||
function latestTask(tasks: Task[]) {
|
||||
return sortTasksByUpdated(tasks)[0] ?? null;
|
||||
}
|
||||
|
||||
function taskIdsMatch(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function sameBatchState(left: FilingSyncBatchState, right: FilingSyncBatchState) {
|
||||
return (
|
||||
left.active === right.active
|
||||
&& left.latestTaskId === right.latestTaskId
|
||||
&& left.startedAt === right.startedAt
|
||||
&& left.finishedAt === right.finishedAt
|
||||
&& left.terminalVisible === right.terminalVisible
|
||||
&& taskIdsMatch(left.taskIds, right.taskIds)
|
||||
);
|
||||
}
|
||||
|
||||
function deriveFilingSyncBatch(
|
||||
current: FilingSyncBatchState,
|
||||
activeTasks: Task[],
|
||||
finishedTasks: Task[]
|
||||
) {
|
||||
const activeSyncTasks = sortTasksByUpdated(
|
||||
activeTasks.filter((task) => task.task_type === 'sync_filings')
|
||||
);
|
||||
|
||||
if (activeSyncTasks.length > 0) {
|
||||
const resetBatch = current.terminalVisible || (!current.active && current.taskIds.length === 0);
|
||||
const taskIds = resetBatch
|
||||
? activeSyncTasks.map((task) => task.id)
|
||||
: [...new Set([...current.taskIds, ...activeSyncTasks.map((task) => task.id)])];
|
||||
const newestTask = activeSyncTasks[0] ?? null;
|
||||
const oldestActiveTask = [...activeSyncTasks].sort((left, right) => (
|
||||
new Date(left.created_at).getTime() - new Date(right.created_at).getTime()
|
||||
))[0] ?? null;
|
||||
|
||||
return {
|
||||
active: true,
|
||||
taskIds,
|
||||
latestTaskId: newestTask?.id ?? current.latestTaskId,
|
||||
startedAt: resetBatch ? (oldestActiveTask?.created_at ?? newestTask?.created_at ?? null) : current.startedAt,
|
||||
finishedAt: null,
|
||||
terminalVisible: false
|
||||
} satisfies FilingSyncBatchState;
|
||||
}
|
||||
|
||||
if (current.taskIds.length > 0 && (current.active || current.terminalVisible)) {
|
||||
const batchTaskIds = new Set(current.taskIds);
|
||||
const terminalMembers = finishedTasks.filter((task) => (
|
||||
task.task_type === 'sync_filings' && batchTaskIds.has(task.id)
|
||||
));
|
||||
const newestTerminalTask = latestTask(terminalMembers);
|
||||
|
||||
return {
|
||||
...current,
|
||||
active: false,
|
||||
latestTaskId: newestTerminalTask?.id ?? current.latestTaskId,
|
||||
finishedAt: newestTerminalTask?.updated_at ?? current.finishedAt ?? current.startedAt,
|
||||
terminalVisible: true
|
||||
} satisfies FilingSyncBatchState;
|
||||
}
|
||||
|
||||
return EMPTY_FILING_SYNC_BATCH_STATE;
|
||||
}
|
||||
|
||||
function toastIdForEntry(entry: TaskNotificationEntry) {
|
||||
return isFilingSyncEntry(entry) ? 'toast:filing-sync' : entry.primaryTaskId;
|
||||
}
|
||||
|
||||
function entryProgressLabel(entry: TaskNotificationEntry) {
|
||||
const progress = entry.progress;
|
||||
if (!progress) {
|
||||
return null;
|
||||
}
|
||||
@@ -37,44 +126,31 @@ function taskProgressLabel(task: Task) {
|
||||
return `${progress.current}/${progress.total} ${progress.unit}`;
|
||||
}
|
||||
|
||||
function taskDescription(task: Task) {
|
||||
const lines = [
|
||||
task.notification.statusLine,
|
||||
task.notification.detailLine,
|
||||
taskProgressLabel(task)
|
||||
].filter((value): value is string => Boolean(value));
|
||||
|
||||
return lines.join(' • ');
|
||||
}
|
||||
|
||||
function taskTitle(task: Task) {
|
||||
return task.notification.title;
|
||||
}
|
||||
|
||||
function terminalToastDescription(task: Task) {
|
||||
const topStat = task.notification.stats[0];
|
||||
function entryDescription(entry: TaskNotificationEntry) {
|
||||
return [
|
||||
task.notification.statusLine,
|
||||
topStat ? `${topStat.label}: ${topStat.value}` : null,
|
||||
task.notification.detailLine
|
||||
entry.statusLine,
|
||||
entry.detailLine,
|
||||
entryProgressLabel(entry)
|
||||
].filter((value): value is string => Boolean(value)).join(' • ');
|
||||
}
|
||||
|
||||
function shouldNotifyTask(task: Task) {
|
||||
return !task.notification_silenced_at;
|
||||
}
|
||||
function terminalToastDescription(entry: TaskNotificationEntry) {
|
||||
const topStat = entry.stats[0];
|
||||
|
||||
function isUnread(task: Task) {
|
||||
return task.notification_read_at === null;
|
||||
return [
|
||||
entry.statusLine,
|
||||
topStat ? `${topStat.label}: ${topStat.value}` : null,
|
||||
entry.detailLine
|
||||
].filter((value): value is string => Boolean(value)).join(' • ');
|
||||
}
|
||||
|
||||
type UseTaskNotificationsCenterResult = {
|
||||
activeTasks: Task[];
|
||||
finishedTasks: Task[];
|
||||
activeEntries: TaskNotificationEntry[];
|
||||
finishedEntries: TaskNotificationEntry[];
|
||||
unreadCount: number;
|
||||
isLoading: boolean;
|
||||
awaitingReviewTasks: Task[];
|
||||
visibleFinishedTasks: Task[];
|
||||
awaitingReviewEntries: TaskNotificationEntry[];
|
||||
visibleFinishedEntries: TaskNotificationEntry[];
|
||||
showReadFinished: boolean;
|
||||
setShowReadFinished: (value: boolean) => void;
|
||||
isPopoverOpen: boolean;
|
||||
@@ -84,9 +160,9 @@ type UseTaskNotificationsCenterResult = {
|
||||
isDetailOpen: boolean;
|
||||
setIsDetailOpen: (value: boolean) => void;
|
||||
openTaskDetails: (taskId: string) => void;
|
||||
openTaskAction: (task: Task, actionId?: string | null) => void;
|
||||
markTaskRead: (taskId: string, read?: boolean) => Promise<void>;
|
||||
silenceTask: (taskId: string, silenced?: boolean) => Promise<void>;
|
||||
openTaskAction: (entry: TaskNotificationEntry, actionId?: string | null) => void;
|
||||
markEntryRead: (entry: TaskNotificationEntry, read?: boolean) => Promise<void>;
|
||||
silenceEntry: (entry: TaskNotificationEntry, silenced?: boolean) => Promise<void>;
|
||||
refreshTasks: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -95,6 +171,7 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTasks, setActiveTasks] = useState<Task[]>([]);
|
||||
const [finishedTasks, setFinishedTasks] = useState<Task[]>([]);
|
||||
const [filingSyncBatch, setFilingSyncBatch] = useState<FilingSyncBatchState>(EMPTY_FILING_SYNC_BATCH_STATE);
|
||||
const [showReadFinished, setShowReadFinished] = useState(false);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [hasLoadedActive, setHasLoadedActive] = useState(false);
|
||||
@@ -108,6 +185,9 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
const invalidatedTerminalRef = useRef(new Set<string>());
|
||||
const activeSnapshotRef = useRef<Task[]>([]);
|
||||
const finishedSnapshotRef = useRef<Task[]>([]);
|
||||
const filingSyncBatchRef = useRef<FilingSyncBatchState>(EMPTY_FILING_SYNC_BATCH_STATE);
|
||||
const silenceEntryRef = useRef<(entry: TaskNotificationEntry, silenced?: boolean) => Promise<void>>(async () => {});
|
||||
const markEntryReadRef = useRef<(entry: TaskNotificationEntry, read?: boolean) => Promise<void>>(async () => {});
|
||||
const [isDocumentVisible, setIsDocumentVisible] = useState(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return true;
|
||||
@@ -116,9 +196,23 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
return document.visibilityState === 'visible';
|
||||
});
|
||||
|
||||
const applyTaskLocally = useCallback((task: Task) => {
|
||||
setActiveTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry)));
|
||||
setFinishedTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry)));
|
||||
const syncBatchState = useCallback((nextBatch: FilingSyncBatchState) => {
|
||||
filingSyncBatchRef.current = nextBatch;
|
||||
setFilingSyncBatch((current) => sameBatchState(current, nextBatch) ? current : nextBatch);
|
||||
}, []);
|
||||
|
||||
const mergeTasksLocally = useCallback((tasks: Task[]) => {
|
||||
if (tasks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskMap = new Map(tasks.map((task) => [task.id, task]));
|
||||
const mergeList = (list: Task[]) => list.map((entry) => taskMap.get(entry.id) ?? entry);
|
||||
|
||||
activeSnapshotRef.current = mergeList(activeSnapshotRef.current);
|
||||
finishedSnapshotRef.current = mergeList(finishedSnapshotRef.current);
|
||||
setActiveTasks((current) => mergeList(current));
|
||||
setFinishedTasks((current) => mergeList(current));
|
||||
}, []);
|
||||
|
||||
const invalidateForTerminalTask = useCallback((task: Task) => {
|
||||
@@ -163,15 +257,15 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
setIsPopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
const openTaskAction = useCallback((task: Task, actionId?: string | null) => {
|
||||
const openTaskAction = useCallback((entry: TaskNotificationEntry, actionId?: string | null) => {
|
||||
const action = actionId
|
||||
? task.notification.actions.find((entry) => entry.id === actionId)
|
||||
: task.notification.actions.find((entry) => entry.primary && entry.id !== 'open_details')
|
||||
?? task.notification.actions.find((entry) => entry.id !== 'open_details')
|
||||
? entry.actions.find((candidate) => candidate.id === actionId)
|
||||
: entry.actions.find((candidate) => candidate.primary && candidate.id !== 'open_details')
|
||||
?? entry.actions.find((candidate) => candidate.id !== 'open_details')
|
||||
?? null;
|
||||
|
||||
if (!action || action.id === 'open_details' || !action.href) {
|
||||
openTaskDetails(task.id);
|
||||
openTaskDetails(entry.primaryTaskId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,121 +273,205 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
router.push(action.href);
|
||||
}, [openTaskDetails, router]);
|
||||
|
||||
const silenceTask = useCallback(async (taskId: string, silenced = true) => {
|
||||
try {
|
||||
const { task } = await updateTaskNotificationState(taskId, { silenced });
|
||||
applyTaskLocally(task);
|
||||
toast.dismiss(taskId);
|
||||
} catch {
|
||||
toast.error('Unable to update notification state');
|
||||
}
|
||||
}, [applyTaskLocally]);
|
||||
const emitEntryToast = useCallback((entry: TaskNotificationEntry) => {
|
||||
const toastId = toastIdForEntry(entry);
|
||||
|
||||
const markTaskRead = useCallback(async (taskId: string, read = true) => {
|
||||
try {
|
||||
const { task } = await updateTaskNotificationState(taskId, { read });
|
||||
applyTaskLocally(task);
|
||||
if (read) {
|
||||
toast.dismiss(taskId);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Unable to update notification state');
|
||||
}
|
||||
}, [applyTaskLocally]);
|
||||
|
||||
const emitTaskToast = useCallback((task: Task) => {
|
||||
if (!shouldNotifyTask(task)) {
|
||||
toast.dismiss(task.id);
|
||||
if (!shouldNotifyEntry(entry)) {
|
||||
toast.dismiss(toastId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.status === 'queued' || task.status === 'running') {
|
||||
toast(taskTitle(task), {
|
||||
id: task.id,
|
||||
if (entry.status === 'queued' || entry.status === 'running') {
|
||||
toast(entry.title, {
|
||||
id: toastId,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
description: taskDescription(task),
|
||||
description: entryDescription(entry),
|
||||
action: {
|
||||
label: 'Open details',
|
||||
onClick: () => openTaskDetails(task.id)
|
||||
onClick: () => openTaskDetails(entry.primaryTaskId)
|
||||
},
|
||||
cancel: {
|
||||
label: 'Silence',
|
||||
onClick: () => {
|
||||
void silenceTask(task.id, true);
|
||||
void silenceEntryRef.current(entry, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const toastBuilder = task.status === 'completed' ? toast.success : toast.error;
|
||||
const primaryAction = task.notification.actions.find((entry) => entry.primary && entry.id !== 'open_details')
|
||||
?? task.notification.actions.find((entry) => entry.id !== 'open_details')
|
||||
const toastBuilder = entry.status === 'completed' ? toast.success : toast.error;
|
||||
const primaryAction = entry.actions.find((candidate) => candidate.primary && candidate.id !== 'open_details')
|
||||
?? entry.actions.find((candidate) => candidate.id !== 'open_details')
|
||||
?? null;
|
||||
|
||||
toastBuilder(taskTitle(task), {
|
||||
id: task.id,
|
||||
toastBuilder(entry.title, {
|
||||
id: toastId,
|
||||
duration: 10_000,
|
||||
description: terminalToastDescription(task),
|
||||
description: terminalToastDescription(entry),
|
||||
action: {
|
||||
label: primaryAction?.label ?? 'Open details',
|
||||
onClick: () => {
|
||||
if (primaryAction) {
|
||||
openTaskAction(task, primaryAction.id);
|
||||
openTaskAction(entry, primaryAction.id);
|
||||
return;
|
||||
}
|
||||
|
||||
openTaskDetails(task.id);
|
||||
openTaskDetails(entry.primaryTaskId);
|
||||
}
|
||||
},
|
||||
cancel: {
|
||||
label: 'Mark read',
|
||||
onClick: () => {
|
||||
void markTaskRead(task.id, true);
|
||||
void markEntryReadRef.current(entry, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [markTaskRead, openTaskAction, openTaskDetails, silenceTask]);
|
||||
|
||||
const processSnapshots = useCallback(() => {
|
||||
const active = activeSnapshotRef.current;
|
||||
const finished = finishedSnapshotRef.current;
|
||||
const all = [...active, ...finished];
|
||||
}, [openTaskAction, openTaskDetails]);
|
||||
|
||||
const processSnapshots = useCallback((nextBatch = filingSyncBatchRef.current) => {
|
||||
if (!activeLoadedRef.current || !finishedLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = buildNotificationEntries({
|
||||
activeTasks: activeSnapshotRef.current,
|
||||
finishedTasks: finishedSnapshotRef.current,
|
||||
filingSyncBatch: nextBatch
|
||||
});
|
||||
|
||||
if (stateSignaturesRef.current.size === 0) {
|
||||
for (const task of all) {
|
||||
stateSignaturesRef.current.set(task.id, taskSignature(task));
|
||||
for (const entry of entries) {
|
||||
stateSignaturesRef.current.set(entry.id, notificationEntrySignature(entry));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const task of all) {
|
||||
const signature = taskSignature(task);
|
||||
const previousSignature = stateSignaturesRef.current.get(task.id);
|
||||
for (const entry of entries) {
|
||||
const signature = notificationEntrySignature(entry);
|
||||
const previousSignature = stateSignaturesRef.current.get(entry.id);
|
||||
const wasKnown = previousSignature !== undefined;
|
||||
|
||||
if (!wasKnown || previousSignature !== signature) {
|
||||
emitTaskToast(task);
|
||||
emitEntryToast(entry);
|
||||
|
||||
if (isTerminalTask(task)) {
|
||||
invalidateForTerminalTask(task);
|
||||
if (!isFilingSyncEntry(entry) && isTerminalEntry(entry)) {
|
||||
const terminalTask = [
|
||||
...activeSnapshotRef.current,
|
||||
...finishedSnapshotRef.current
|
||||
].find((task) => task.id === entry.primaryTaskId);
|
||||
|
||||
if (terminalTask) {
|
||||
invalidateForTerminalTask(terminalTask);
|
||||
}
|
||||
}
|
||||
|
||||
if (isFilingSyncEntry(entry) && isTerminalEntry(entry)) {
|
||||
for (const task of [...activeSnapshotRef.current, ...finishedSnapshotRef.current]) {
|
||||
if (task.task_type === 'sync_filings' && entry.taskIds.includes(task.id) && isTerminalTask(task)) {
|
||||
invalidateForTerminalTask(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stateSignaturesRef.current.set(task.id, signature);
|
||||
stateSignaturesRef.current.set(entry.id, signature);
|
||||
}
|
||||
|
||||
const currentIds = new Set(all.map((task) => task.id));
|
||||
const currentIds = new Set(entries.map((entry) => entry.id));
|
||||
for (const knownId of [...stateSignaturesRef.current.keys()]) {
|
||||
if (!currentIds.has(knownId)) {
|
||||
toast.dismiss(knownId);
|
||||
const toastId = knownId === 'filing-sync:active' ? 'toast:filing-sync' : knownId;
|
||||
toast.dismiss(toastId);
|
||||
stateSignaturesRef.current.delete(knownId);
|
||||
}
|
||||
}
|
||||
}, [emitTaskToast, invalidateForTerminalTask]);
|
||||
}, [emitEntryToast, invalidateForTerminalTask]);
|
||||
|
||||
const applySnapshotState = useCallback((
|
||||
nextActiveTasks: Task[],
|
||||
nextFinishedTasks: Task[],
|
||||
loaded: { active?: boolean; finished?: boolean } = {}
|
||||
) => {
|
||||
activeSnapshotRef.current = nextActiveTasks;
|
||||
finishedSnapshotRef.current = nextFinishedTasks;
|
||||
|
||||
if (loaded.active) {
|
||||
activeLoadedRef.current = true;
|
||||
setHasLoadedActive(true);
|
||||
}
|
||||
|
||||
if (loaded.finished) {
|
||||
finishedLoadedRef.current = true;
|
||||
setHasLoadedFinished(true);
|
||||
}
|
||||
|
||||
setActiveTasks(nextActiveTasks);
|
||||
setFinishedTasks(nextFinishedTasks);
|
||||
|
||||
const nextBatch = deriveFilingSyncBatch(
|
||||
filingSyncBatchRef.current,
|
||||
nextActiveTasks,
|
||||
nextFinishedTasks
|
||||
);
|
||||
|
||||
syncBatchState(nextBatch);
|
||||
processSnapshots(nextBatch);
|
||||
}, [processSnapshots, syncBatchState]);
|
||||
|
||||
const updateEntryNotification = useCallback(async (
|
||||
entry: TaskNotificationEntry,
|
||||
input: { read?: boolean; silenced?: boolean }
|
||||
) => {
|
||||
const results = await Promise.allSettled(
|
||||
entry.taskIds.map((taskId) => updateTaskNotificationState(taskId, input))
|
||||
);
|
||||
const updatedTasks = results.flatMap((result) => (
|
||||
result.status === 'fulfilled' ? [result.value.task] : []
|
||||
));
|
||||
|
||||
if (updatedTasks.length > 0) {
|
||||
mergeTasksLocally(updatedTasks);
|
||||
}
|
||||
|
||||
let nextBatch = deriveFilingSyncBatch(
|
||||
filingSyncBatchRef.current,
|
||||
activeSnapshotRef.current,
|
||||
finishedSnapshotRef.current
|
||||
);
|
||||
|
||||
if (
|
||||
isFilingSyncEntry(entry)
|
||||
&& isTerminalEntry(entry)
|
||||
&& (input.read || input.silenced)
|
||||
&& results.every((result) => result.status === 'fulfilled')
|
||||
) {
|
||||
nextBatch = EMPTY_FILING_SYNC_BATCH_STATE;
|
||||
}
|
||||
|
||||
syncBatchState(nextBatch);
|
||||
processSnapshots(nextBatch);
|
||||
|
||||
if (results.some((result) => result.status === 'rejected')) {
|
||||
toast.error('Unable to update notification state');
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.read || input.silenced) {
|
||||
toast.dismiss(toastIdForEntry(entry));
|
||||
}
|
||||
}, [mergeTasksLocally, processSnapshots, syncBatchState]);
|
||||
|
||||
const silenceEntry = useCallback(async (entry: TaskNotificationEntry, silenced = true) => {
|
||||
await updateEntryNotification(entry, { silenced });
|
||||
}, [updateEntryNotification]);
|
||||
|
||||
const markEntryRead = useCallback(async (entry: TaskNotificationEntry, read = true) => {
|
||||
await updateEntryNotification(entry, { read });
|
||||
}, [updateEntryNotification]);
|
||||
|
||||
silenceEntryRef.current = silenceEntry;
|
||||
markEntryReadRef.current = markEntryRead;
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
try {
|
||||
@@ -308,19 +486,11 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
})
|
||||
]);
|
||||
|
||||
activeSnapshotRef.current = activeRes.tasks;
|
||||
finishedSnapshotRef.current = finishedRes.tasks;
|
||||
activeLoadedRef.current = true;
|
||||
finishedLoadedRef.current = true;
|
||||
setHasLoadedActive(true);
|
||||
setHasLoadedFinished(true);
|
||||
setActiveTasks(activeRes.tasks);
|
||||
setFinishedTasks(finishedRes.tasks);
|
||||
processSnapshots();
|
||||
applySnapshotState(activeRes.tasks, finishedRes.tasks, { active: true, finished: true });
|
||||
} catch {
|
||||
// ignore transient polling failures
|
||||
}
|
||||
}, [processSnapshots]);
|
||||
}, [applySnapshotState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
@@ -364,7 +534,13 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
return 4_000;
|
||||
}
|
||||
|
||||
if (finishedSnapshotRef.current.some((task) => isUnread(task))) {
|
||||
const terminalEntries = buildNotificationEntries({
|
||||
activeTasks: activeSnapshotRef.current,
|
||||
finishedTasks: finishedSnapshotRef.current,
|
||||
filingSyncBatch: filingSyncBatchRef.current
|
||||
}).filter(isTerminalEntry);
|
||||
|
||||
if (terminalEntries.some((entry) => isUnreadEntry(entry))) {
|
||||
return 15_000;
|
||||
}
|
||||
|
||||
@@ -386,11 +562,7 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSnapshotRef.current = response.tasks;
|
||||
activeLoadedRef.current = true;
|
||||
setHasLoadedActive(true);
|
||||
setActiveTasks(response.tasks);
|
||||
processSnapshots();
|
||||
applySnapshotState(response.tasks, finishedSnapshotRef.current, { active: true });
|
||||
} catch {
|
||||
// ignore transient polling failures
|
||||
}
|
||||
@@ -413,13 +585,27 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
return;
|
||||
}
|
||||
|
||||
finishedSnapshotRef.current = response.tasks;
|
||||
finishedLoadedRef.current = true;
|
||||
setHasLoadedFinished(true);
|
||||
setFinishedTasks(response.tasks);
|
||||
processSnapshots();
|
||||
applySnapshotState(activeSnapshotRef.current, response.tasks, { finished: true });
|
||||
|
||||
const signature = response.tasks
|
||||
.map((task) => notificationEntrySignature({
|
||||
id: task.id,
|
||||
kind: 'single',
|
||||
status: task.status,
|
||||
title: task.notification.title,
|
||||
statusLine: task.notification.statusLine,
|
||||
detailLine: task.notification.detailLine,
|
||||
progress: task.notification.progress,
|
||||
stats: task.notification.stats,
|
||||
updatedAt: task.updated_at,
|
||||
primaryTaskId: task.id,
|
||||
taskIds: [task.id],
|
||||
actions: task.notification.actions,
|
||||
notificationReadAt: task.notification_read_at,
|
||||
notificationSilencedAt: task.notification_silenced_at
|
||||
}))
|
||||
.join('||');
|
||||
|
||||
const signature = response.tasks.map((task) => taskSignature(task)).join('||');
|
||||
if (signature === previousTerminalSignature) {
|
||||
stableTerminalPolls += 1;
|
||||
} else {
|
||||
@@ -445,43 +631,52 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
clearTimeout(terminalTimer);
|
||||
}
|
||||
};
|
||||
}, [isDetailOpen, isDocumentVisible, isPopoverOpen, processSnapshots]);
|
||||
}, [applySnapshotState, isDetailOpen, isDocumentVisible, isPopoverOpen]);
|
||||
|
||||
const normalizedActiveTasks = useMemo(() => {
|
||||
return activeTasks.filter((task) => ACTIVE_STATUSES.includes(task.status));
|
||||
}, [activeTasks]);
|
||||
const entries = useMemo(() => buildNotificationEntries({
|
||||
activeTasks,
|
||||
finishedTasks,
|
||||
filingSyncBatch
|
||||
}), [activeTasks, filingSyncBatch, finishedTasks]);
|
||||
|
||||
const normalizedFinishedTasks = useMemo(() => {
|
||||
return finishedTasks.filter((task) => TERMINAL_STATUSES.includes(task.status));
|
||||
}, [finishedTasks]);
|
||||
const activeEntries = useMemo(() => {
|
||||
return entries.filter((entry) => ACTIVE_STATUSES.includes(entry.status));
|
||||
}, [entries]);
|
||||
|
||||
const awaitingReviewTasks = useMemo(() => {
|
||||
return normalizedFinishedTasks.filter((task) => isUnread(task));
|
||||
}, [normalizedFinishedTasks]);
|
||||
const normalizedFinishedEntries = useMemo(() => {
|
||||
return entries.filter((entry) => TERMINAL_STATUSES.includes(entry.status));
|
||||
}, [entries]);
|
||||
|
||||
const visibleFinishedTasks = useMemo(() => {
|
||||
const awaitingReviewEntries = useMemo(() => {
|
||||
return normalizedFinishedEntries.filter((entry) => isUnreadEntry(entry));
|
||||
}, [normalizedFinishedEntries]);
|
||||
|
||||
const visibleFinishedEntries = useMemo(() => {
|
||||
if (showReadFinished) {
|
||||
return normalizedFinishedTasks;
|
||||
return normalizedFinishedEntries;
|
||||
}
|
||||
|
||||
return awaitingReviewTasks;
|
||||
}, [awaitingReviewTasks, normalizedFinishedTasks, showReadFinished]);
|
||||
return awaitingReviewEntries;
|
||||
}, [awaitingReviewEntries, normalizedFinishedEntries, showReadFinished]);
|
||||
|
||||
const unreadCount = useMemo(() => {
|
||||
const unreadTerminal = normalizedFinishedTasks.filter((task) => isUnread(task)).length;
|
||||
const unreadActive = normalizedActiveTasks.filter((task) => isUnread(task) && !task.notification_silenced_at).length;
|
||||
const unreadTerminal = normalizedFinishedEntries.filter((entry) => isUnreadEntry(entry)).length;
|
||||
const unreadActive = activeEntries.filter((entry) => (
|
||||
isUnreadEntry(entry) && entry.notificationSilencedAt === null
|
||||
)).length;
|
||||
|
||||
return unreadTerminal + unreadActive;
|
||||
}, [normalizedActiveTasks, normalizedFinishedTasks]);
|
||||
}, [activeEntries, normalizedFinishedEntries]);
|
||||
|
||||
const isLoading = !hasLoadedActive || !hasLoadedFinished;
|
||||
|
||||
return {
|
||||
activeTasks: normalizedActiveTasks,
|
||||
finishedTasks: normalizedFinishedTasks,
|
||||
activeEntries,
|
||||
finishedEntries: normalizedFinishedEntries,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
awaitingReviewTasks,
|
||||
visibleFinishedTasks,
|
||||
awaitingReviewEntries,
|
||||
visibleFinishedEntries,
|
||||
showReadFinished,
|
||||
setShowReadFinished,
|
||||
isPopoverOpen,
|
||||
@@ -492,8 +687,8 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
||||
setIsDetailOpen,
|
||||
openTaskDetails,
|
||||
openTaskAction,
|
||||
markTaskRead,
|
||||
silenceTask,
|
||||
markEntryRead,
|
||||
silenceEntry,
|
||||
refreshTasks
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user