Files
Neon-Desk/hooks/use-task-notifications-center.ts

695 lines
22 KiB
TypeScript

'use client';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
listRecentTasks,
updateTaskNotificationState
} from '@/lib/api';
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'];
function isTerminalTask(task: Task) {
return TERMINAL_STATUSES.includes(task.status);
}
function isTerminalEntry(entry: TaskNotificationEntry) {
return TERMINAL_STATUSES.includes(entry.status);
}
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;
}
return `${progress.current}/${progress.total} ${progress.unit}`;
}
function entryDescription(entry: TaskNotificationEntry) {
return [
entry.statusLine,
entry.detailLine,
entryProgressLabel(entry)
].filter((value): value is string => Boolean(value)).join(' • ');
}
function terminalToastDescription(entry: TaskNotificationEntry) {
const topStat = entry.stats[0];
return [
entry.statusLine,
topStat ? `${topStat.label}: ${topStat.value}` : null,
entry.detailLine
].filter((value): value is string => Boolean(value)).join(' • ');
}
type UseTaskNotificationsCenterResult = {
activeEntries: TaskNotificationEntry[];
finishedEntries: TaskNotificationEntry[];
unreadCount: number;
isLoading: boolean;
awaitingReviewEntries: TaskNotificationEntry[];
visibleFinishedEntries: TaskNotificationEntry[];
showReadFinished: boolean;
setShowReadFinished: (value: boolean) => void;
isPopoverOpen: boolean;
setIsPopoverOpen: (value: boolean) => void;
detailTaskId: string | null;
setDetailTaskId: (value: string | null) => void;
isDetailOpen: boolean;
setIsDetailOpen: (value: boolean) => void;
openTaskDetails: (taskId: string) => 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>;
};
export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
const router = useRouter();
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);
const [hasLoadedFinished, setHasLoadedFinished] = useState(false);
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const activeLoadedRef = useRef(false);
const finishedLoadedRef = useRef(false);
const stateSignaturesRef = useRef(new Map<string, string>());
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;
}
return document.visibilityState === 'visible';
});
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) => {
const key = `${task.id}:${task.status}`;
if (invalidatedTerminalRef.current.has(key)) {
return;
}
invalidatedTerminalRef.current.add(key);
void queryClient.invalidateQueries({ queryKey: ['tasks'] });
switch (task.task_type) {
case 'sync_filings': {
void queryClient.invalidateQueries({ queryKey: ['filings'] });
void queryClient.invalidateQueries({ queryKey: ['analysis'] });
void queryClient.invalidateQueries({ queryKey: ['financials-v3'] });
break;
}
case 'analyze_filing': {
void queryClient.invalidateQueries({ queryKey: ['filings'] });
void queryClient.invalidateQueries({ queryKey: ['report'] });
void queryClient.invalidateQueries({ queryKey: ['analysis'] });
break;
}
case 'refresh_prices': {
void queryClient.invalidateQueries({ queryKey: ['portfolio', 'holdings'] });
void queryClient.invalidateQueries({ queryKey: ['portfolio', 'summary'] });
break;
}
case 'portfolio_insights': {
void queryClient.invalidateQueries({ queryKey: ['portfolio', 'insights', 'latest'] });
break;
}
default:
break;
}
}, [queryClient]);
const openTaskDetails = useCallback((taskId: string) => {
setDetailTaskId(taskId);
setIsDetailOpen(true);
setIsPopoverOpen(false);
}, []);
const openTaskAction = useCallback((entry: TaskNotificationEntry, actionId?: string | null) => {
const action = actionId
? 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(entry.primaryTaskId);
return;
}
setIsPopoverOpen(false);
router.push(action.href);
}, [openTaskDetails, router]);
const emitEntryToast = useCallback((entry: TaskNotificationEntry) => {
const toastId = toastIdForEntry(entry);
if (!shouldNotifyEntry(entry)) {
toast.dismiss(toastId);
return;
}
if (entry.status === 'queued' || entry.status === 'running') {
toast(entry.title, {
id: toastId,
duration: Number.POSITIVE_INFINITY,
description: entryDescription(entry),
action: {
label: 'Open details',
onClick: () => openTaskDetails(entry.primaryTaskId)
},
cancel: {
label: 'Silence',
onClick: () => {
void silenceEntryRef.current(entry, true);
}
}
});
return;
}
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(entry.title, {
id: toastId,
duration: 10_000,
description: terminalToastDescription(entry),
action: {
label: primaryAction?.label ?? 'Open details',
onClick: () => {
if (primaryAction) {
openTaskAction(entry, primaryAction.id);
return;
}
openTaskDetails(entry.primaryTaskId);
}
},
cancel: {
label: 'Mark read',
onClick: () => {
void markEntryReadRef.current(entry, true);
}
}
});
}, [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 entry of entries) {
stateSignaturesRef.current.set(entry.id, notificationEntrySignature(entry));
}
return;
}
for (const entry of entries) {
const signature = notificationEntrySignature(entry);
const previousSignature = stateSignaturesRef.current.get(entry.id);
const wasKnown = previousSignature !== undefined;
if (!wasKnown || previousSignature !== signature) {
emitEntryToast(entry);
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(entry.id, signature);
}
const currentIds = new Set(entries.map((entry) => entry.id));
for (const knownId of [...stateSignaturesRef.current.keys()]) {
if (!currentIds.has(knownId)) {
const toastId = knownId === 'filing-sync:active' ? 'toast:filing-sync' : knownId;
toast.dismiss(toastId);
stateSignaturesRef.current.delete(knownId);
}
}
}, [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 {
const [activeRes, finishedRes] = await Promise.all([
listRecentTasks({
limit: 80,
statuses: ACTIVE_STATUSES
}),
listRecentTasks({
limit: 120,
statuses: TERMINAL_STATUSES
})
]);
applySnapshotState(activeRes.tasks, finishedRes.tasks, { active: true, finished: true });
} catch {
// ignore transient polling failures
}
}, [applySnapshotState]);
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
const onVisibilityChange = () => {
setIsDocumentVisible(document.visibilityState === 'visible');
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, []);
useEffect(() => {
let cancelled = false;
let activeTimer: ReturnType<typeof setTimeout> | null = null;
let terminalTimer: ReturnType<typeof setTimeout> | null = null;
let stableTerminalPolls = 0;
let previousTerminalSignature = '';
const nextActiveDelay = () => {
if (!isDocumentVisible) {
return 30_000;
}
const hasActiveTasks = activeSnapshotRef.current.length > 0;
if (isPopoverOpen || isDetailOpen || hasActiveTasks) {
return 2_000;
}
return 12_000;
};
const nextTerminalDelay = () => {
if (!isDocumentVisible) {
return 60_000;
}
if (isPopoverOpen || isDetailOpen) {
return 4_000;
}
const terminalEntries = buildNotificationEntries({
activeTasks: activeSnapshotRef.current,
finishedTasks: finishedSnapshotRef.current,
filingSyncBatch: filingSyncBatchRef.current
}).filter(isTerminalEntry);
if (terminalEntries.some((entry) => isUnreadEntry(entry))) {
return 15_000;
}
return stableTerminalPolls >= 2 ? 45_000 : 20_000;
};
const runActiveLoop = async () => {
if (cancelled) {
return;
}
try {
const response = await listRecentTasks({
limit: 80,
statuses: ACTIVE_STATUSES
});
if (cancelled) {
return;
}
applySnapshotState(response.tasks, finishedSnapshotRef.current, { active: true });
} catch {
// ignore transient polling failures
}
activeTimer = setTimeout(runActiveLoop, nextActiveDelay());
};
const runTerminalLoop = async () => {
if (cancelled) {
return;
}
try {
const response = await listRecentTasks({
limit: 120,
statuses: TERMINAL_STATUSES
});
if (cancelled) {
return;
}
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('||');
if (signature === previousTerminalSignature) {
stableTerminalPolls += 1;
} else {
stableTerminalPolls = 0;
previousTerminalSignature = signature;
}
} catch {
// ignore transient polling failures
}
terminalTimer = setTimeout(runTerminalLoop, nextTerminalDelay());
};
void runActiveLoop();
void runTerminalLoop();
return () => {
cancelled = true;
if (activeTimer) {
clearTimeout(activeTimer);
}
if (terminalTimer) {
clearTimeout(terminalTimer);
}
};
}, [applySnapshotState, isDetailOpen, isDocumentVisible, isPopoverOpen]);
const entries = useMemo(() => buildNotificationEntries({
activeTasks,
finishedTasks,
filingSyncBatch
}), [activeTasks, filingSyncBatch, finishedTasks]);
const activeEntries = useMemo(() => {
return entries.filter((entry) => ACTIVE_STATUSES.includes(entry.status));
}, [entries]);
const normalizedFinishedEntries = useMemo(() => {
return entries.filter((entry) => TERMINAL_STATUSES.includes(entry.status));
}, [entries]);
const awaitingReviewEntries = useMemo(() => {
return normalizedFinishedEntries.filter((entry) => isUnreadEntry(entry));
}, [normalizedFinishedEntries]);
const visibleFinishedEntries = useMemo(() => {
if (showReadFinished) {
return normalizedFinishedEntries;
}
return awaitingReviewEntries;
}, [awaitingReviewEntries, normalizedFinishedEntries, showReadFinished]);
const unreadCount = useMemo(() => {
const unreadTerminal = normalizedFinishedEntries.filter((entry) => isUnreadEntry(entry)).length;
const unreadActive = activeEntries.filter((entry) => (
isUnreadEntry(entry) && entry.notificationSilencedAt === null
)).length;
return unreadTerminal + unreadActive;
}, [activeEntries, normalizedFinishedEntries]);
const isLoading = !hasLoadedActive || !hasLoadedFinished;
return {
activeEntries,
finishedEntries: normalizedFinishedEntries,
unreadCount,
isLoading,
awaitingReviewEntries,
visibleFinishedEntries,
showReadFinished,
setShowReadFinished,
isPopoverOpen,
setIsPopoverOpen,
detailTaskId,
setDetailTaskId,
isDetailOpen,
setIsDetailOpen,
openTaskDetails,
openTaskAction,
markEntryRead,
silenceEntry,
refreshTasks
};
}