Collapse filing sync notifications into one batch surface
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { Bell, BellRing, LoaderCircle } from 'lucide-react';
|
import { Bell, BellRing, LoaderCircle } from 'lucide-react';
|
||||||
import type { Task } from '@/lib/types';
|
import type { TaskNotificationEntry } from '@/lib/types';
|
||||||
import { StatusPill } from '@/components/ui/status-pill';
|
import { StatusPill } from '@/components/ui/status-pill';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -11,20 +11,20 @@ type TaskNotificationsTriggerProps = {
|
|||||||
isPopoverOpen: boolean;
|
isPopoverOpen: boolean;
|
||||||
setIsPopoverOpen: (value: boolean) => void;
|
setIsPopoverOpen: (value: boolean) => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
activeTasks: Task[];
|
activeEntries: TaskNotificationEntry[];
|
||||||
visibleFinishedTasks: Task[];
|
visibleFinishedEntries: TaskNotificationEntry[];
|
||||||
awaitingReviewTasks: Task[];
|
awaitingReviewEntries: TaskNotificationEntry[];
|
||||||
showReadFinished: boolean;
|
showReadFinished: boolean;
|
||||||
setShowReadFinished: (value: boolean) => void;
|
setShowReadFinished: (value: boolean) => void;
|
||||||
openTaskDetails: (taskId: string) => void;
|
openTaskDetails: (taskId: string) => void;
|
||||||
openTaskAction: (task: Task, actionId?: string | null) => void;
|
openTaskAction: (entry: TaskNotificationEntry, actionId?: string | null) => void;
|
||||||
silenceTask: (taskId: string, silenced?: boolean) => Promise<void>;
|
silenceEntry: (entry: TaskNotificationEntry, silenced?: boolean) => Promise<void>;
|
||||||
markTaskRead: (taskId: string, read?: boolean) => Promise<void>;
|
markEntryRead: (entry: TaskNotificationEntry, read?: boolean) => Promise<void>;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ProgressBar({ task }: { task: Task }) {
|
function ProgressBar({ entry }: { entry: TaskNotificationEntry }) {
|
||||||
const progress = task.notification.progress;
|
const progress = entry.progress;
|
||||||
if (!progress) {
|
if (!progress) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -45,14 +45,14 @@ function ProgressBar({ task }: { task: Task }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatChips({ task }: { task: Task }) {
|
function StatChips({ entry }: { entry: TaskNotificationEntry }) {
|
||||||
if (task.notification.stats.length === 0) {
|
if (entry.stats.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{task.notification.stats.map((stat) => (
|
{entry.stats.map((stat) => (
|
||||||
<span
|
<span
|
||||||
key={`${stat.label}:${stat.value}`}
|
key={`${stat.label}:${stat.value}`}
|
||||||
className="inline-flex items-center rounded-full border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-2 py-1 text-[11px] text-[color:var(--terminal-muted)]"
|
className="inline-flex items-center rounded-full border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-2 py-1 text-[11px] text-[color:var(--terminal-muted)]"
|
||||||
@@ -64,20 +64,97 @@ function StatChips({ task }: { task: Task }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationCard({
|
||||||
|
entry,
|
||||||
|
openTaskDetails,
|
||||||
|
openTaskAction,
|
||||||
|
silenceEntry,
|
||||||
|
markEntryRead
|
||||||
|
}: {
|
||||||
|
entry: TaskNotificationEntry;
|
||||||
|
openTaskDetails: (taskId: string) => void;
|
||||||
|
openTaskAction: (entry: TaskNotificationEntry, actionId?: string | null) => void;
|
||||||
|
silenceEntry: (entry: TaskNotificationEntry, silenced?: boolean) => Promise<void>;
|
||||||
|
markEntryRead: (entry: TaskNotificationEntry, read?: boolean) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const isRead = entry.notificationReadAt !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-sm text-[color:var(--terminal-bright)]">{entry.title}</p>
|
||||||
|
<StatusPill status={entry.status} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[color:var(--terminal-bright)]">{entry.statusLine}</p>
|
||||||
|
{entry.detailLine ? (
|
||||||
|
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{entry.detailLine}</p>
|
||||||
|
) : null}
|
||||||
|
<ProgressBar entry={entry} />
|
||||||
|
<StatChips entry={entry} />
|
||||||
|
<p className="mt-1 text-[11px] text-[color:var(--terminal-muted)]">
|
||||||
|
{formatDistanceToNow(new Date(entry.updatedAt), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||||
|
{entry.actions
|
||||||
|
.filter((action) => action.primary && action.id !== 'open_details')
|
||||||
|
.slice(0, 1)
|
||||||
|
.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
||||||
|
onClick={() => openTaskAction(entry, action.id)}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
||||||
|
onClick={() => openTaskDetails(entry.primaryTaskId)}
|
||||||
|
>
|
||||||
|
Open details
|
||||||
|
</button>
|
||||||
|
{entry.status === 'queued' || entry.status === 'running' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]"
|
||||||
|
onClick={() => {
|
||||||
|
void silenceEntry(entry, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Silence
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]"
|
||||||
|
onClick={() => {
|
||||||
|
void markEntryRead(entry, !isRead);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRead ? 'Mark unread' : 'Mark read'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TaskNotificationsTrigger({
|
export function TaskNotificationsTrigger({
|
||||||
unreadCount,
|
unreadCount,
|
||||||
isPopoverOpen,
|
isPopoverOpen,
|
||||||
setIsPopoverOpen,
|
setIsPopoverOpen,
|
||||||
isLoading,
|
isLoading,
|
||||||
activeTasks,
|
activeEntries,
|
||||||
visibleFinishedTasks,
|
visibleFinishedEntries,
|
||||||
awaitingReviewTasks,
|
awaitingReviewEntries,
|
||||||
showReadFinished,
|
showReadFinished,
|
||||||
setShowReadFinished,
|
setShowReadFinished,
|
||||||
openTaskDetails,
|
openTaskDetails,
|
||||||
openTaskAction,
|
openTaskAction,
|
||||||
silenceTask,
|
silenceEntry,
|
||||||
markTaskRead,
|
markEntryRead,
|
||||||
className
|
className
|
||||||
}: TaskNotificationsTriggerProps) {
|
}: TaskNotificationsTriggerProps) {
|
||||||
const button = (
|
const button = (
|
||||||
@@ -128,7 +205,7 @@ export function TaskNotificationsTrigger({
|
|||||||
className="h-4 w-4 accent-[color:var(--accent)]"
|
className="h-4 w-4 accent-[color:var(--accent)]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="mb-2 text-xs text-[color:var(--terminal-muted)]">Unread finished: {awaitingReviewTasks.length}</div>
|
<div className="mb-2 text-xs text-[color:var(--terminal-muted)]">Unread finished: {awaitingReviewEntries.length}</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -148,117 +225,37 @@ export function TaskNotificationsTrigger({
|
|||||||
<div className="h-[calc(100%-5.5rem)] space-y-3 overflow-y-auto pr-1">
|
<div className="h-[calc(100%-5.5rem)] space-y-3 overflow-y-auto pr-1">
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<p className="text-[11px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Active jobs</p>
|
<p className="text-[11px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Active jobs</p>
|
||||||
{activeTasks.length === 0 ? (
|
{activeEntries.length === 0 ? (
|
||||||
<p className="text-xs text-[color:var(--terminal-muted)]">No active jobs.</p>
|
<p className="text-xs text-[color:var(--terminal-muted)]">No active jobs.</p>
|
||||||
) : (
|
) : (
|
||||||
activeTasks.map((task) => (
|
activeEntries.map((entry) => (
|
||||||
<article key={task.id} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2.5">
|
<NotificationCard
|
||||||
<div className="flex items-center justify-between gap-2">
|
key={entry.id}
|
||||||
<p className="text-sm text-[color:var(--terminal-bright)]">{task.notification.title}</p>
|
entry={entry}
|
||||||
<StatusPill status={task.status} />
|
openTaskDetails={openTaskDetails}
|
||||||
</div>
|
openTaskAction={openTaskAction}
|
||||||
<p className="mt-1 text-xs text-[color:var(--terminal-bright)]">{task.notification.statusLine}</p>
|
silenceEntry={silenceEntry}
|
||||||
{task.notification.detailLine ? (
|
markEntryRead={markEntryRead}
|
||||||
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{task.notification.detailLine}</p>
|
/>
|
||||||
) : null}
|
|
||||||
<ProgressBar task={task} />
|
|
||||||
<StatChips task={task} />
|
|
||||||
<p className="mt-1 text-[11px] text-[color:var(--terminal-muted)]">
|
|
||||||
{formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
|
||||||
{task.notification.actions
|
|
||||||
.filter((action) => action.primary && action.id !== 'open_details')
|
|
||||||
.slice(0, 1)
|
|
||||||
.map((action) => (
|
|
||||||
<button
|
|
||||||
key={action.id}
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
|
||||||
onClick={() => openTaskAction(task, action.id)}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
|
||||||
onClick={() => openTaskDetails(task.id)}
|
|
||||||
>
|
|
||||||
Open details
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]"
|
|
||||||
onClick={() => {
|
|
||||||
void silenceTask(task.id, true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Silence
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<p className="text-[11px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Awaiting review</p>
|
<p className="text-[11px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Awaiting review</p>
|
||||||
{visibleFinishedTasks.length === 0 ? (
|
{visibleFinishedEntries.length === 0 ? (
|
||||||
<p className="text-xs text-[color:var(--terminal-muted)]">No finished jobs to review.</p>
|
<p className="text-xs text-[color:var(--terminal-muted)]">No finished jobs to review.</p>
|
||||||
) : (
|
) : (
|
||||||
visibleFinishedTasks.map((task) => {
|
visibleFinishedEntries.map((entry) => (
|
||||||
const isRead = task.notification_read_at !== null;
|
<NotificationCard
|
||||||
|
key={entry.id}
|
||||||
return (
|
entry={entry}
|
||||||
<article key={task.id} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-2.5">
|
openTaskDetails={openTaskDetails}
|
||||||
<div className="flex items-center justify-between gap-2">
|
openTaskAction={openTaskAction}
|
||||||
<p className="text-sm text-[color:var(--terminal-bright)]">{task.notification.title}</p>
|
silenceEntry={silenceEntry}
|
||||||
<StatusPill status={task.status} />
|
markEntryRead={markEntryRead}
|
||||||
</div>
|
/>
|
||||||
<p className="mt-1 text-xs text-[color:var(--terminal-bright)]">{task.notification.statusLine}</p>
|
))
|
||||||
{task.notification.detailLine ? (
|
|
||||||
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{task.notification.detailLine}</p>
|
|
||||||
) : null}
|
|
||||||
<ProgressBar task={task} />
|
|
||||||
<StatChips task={task} />
|
|
||||||
<p className="mt-1 text-[11px] text-[color:var(--terminal-muted)]">
|
|
||||||
{formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
|
||||||
{task.notification.actions
|
|
||||||
.filter((action) => action.primary && action.id !== 'open_details')
|
|
||||||
.slice(0, 1)
|
|
||||||
.map((action) => (
|
|
||||||
<button
|
|
||||||
key={action.id}
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
|
||||||
onClick={() => openTaskAction(task, action.id)}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-[color:var(--accent)] transition hover:text-[color:var(--accent-strong)]"
|
|
||||||
onClick={() => openTaskDetails(task.id)}
|
|
||||||
>
|
|
||||||
Open details
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]"
|
|
||||||
onClick={() => {
|
|
||||||
void markTaskRead(task.id, !isRead);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isRead ? 'Mark unread' : 'Mark read'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -662,15 +662,15 @@ export function AppShell({
|
|||||||
isPopoverOpen={notifications.isPopoverOpen}
|
isPopoverOpen={notifications.isPopoverOpen}
|
||||||
setIsPopoverOpen={notifications.setIsPopoverOpen}
|
setIsPopoverOpen={notifications.setIsPopoverOpen}
|
||||||
isLoading={notifications.isLoading}
|
isLoading={notifications.isLoading}
|
||||||
activeTasks={notifications.activeTasks}
|
activeEntries={notifications.activeEntries}
|
||||||
visibleFinishedTasks={notifications.visibleFinishedTasks}
|
visibleFinishedEntries={notifications.visibleFinishedEntries}
|
||||||
awaitingReviewTasks={notifications.awaitingReviewTasks}
|
awaitingReviewEntries={notifications.awaitingReviewEntries}
|
||||||
showReadFinished={notifications.showReadFinished}
|
showReadFinished={notifications.showReadFinished}
|
||||||
setShowReadFinished={notifications.setShowReadFinished}
|
setShowReadFinished={notifications.setShowReadFinished}
|
||||||
openTaskDetails={notifications.openTaskDetails}
|
openTaskDetails={notifications.openTaskDetails}
|
||||||
openTaskAction={notifications.openTaskAction}
|
openTaskAction={notifications.openTaskAction}
|
||||||
silenceTask={notifications.silenceTask}
|
silenceEntry={notifications.silenceEntry}
|
||||||
markTaskRead={notifications.markTaskRead}
|
markEntryRead={notifications.markEntryRead}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import {
|
|||||||
listRecentTasks,
|
listRecentTasks,
|
||||||
updateTaskNotificationState
|
updateTaskNotificationState
|
||||||
} from '@/lib/api';
|
} 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 ACTIVE_STATUSES: TaskStatus[] = ['queued', 'running'];
|
||||||
const TERMINAL_STATUSES: TaskStatus[] = ['completed', 'failed'];
|
const TERMINAL_STATUSES: TaskStatus[] = ['completed', 'failed'];
|
||||||
@@ -17,19 +24,101 @@ function isTerminalTask(task: Task) {
|
|||||||
return TERMINAL_STATUSES.includes(task.status);
|
return TERMINAL_STATUSES.includes(task.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskSignature(task: Task) {
|
function isTerminalEntry(entry: TaskNotificationEntry) {
|
||||||
return JSON.stringify({
|
return TERMINAL_STATUSES.includes(entry.status);
|
||||||
status: task.status,
|
|
||||||
stage: task.stage,
|
|
||||||
stageDetail: task.stage_detail,
|
|
||||||
stageContext: task.stage_context,
|
|
||||||
error: task.error,
|
|
||||||
result: isTerminalTask(task) ? task.result : null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskProgressLabel(task: Task) {
|
function shouldNotifyEntry(entry: TaskNotificationEntry) {
|
||||||
const progress = task.notification.progress;
|
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) {
|
if (!progress) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -37,44 +126,31 @@ function taskProgressLabel(task: Task) {
|
|||||||
return `${progress.current}/${progress.total} ${progress.unit}`;
|
return `${progress.current}/${progress.total} ${progress.unit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskDescription(task: Task) {
|
function entryDescription(entry: TaskNotificationEntry) {
|
||||||
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];
|
|
||||||
return [
|
return [
|
||||||
task.notification.statusLine,
|
entry.statusLine,
|
||||||
topStat ? `${topStat.label}: ${topStat.value}` : null,
|
entry.detailLine,
|
||||||
task.notification.detailLine
|
entryProgressLabel(entry)
|
||||||
].filter((value): value is string => Boolean(value)).join(' • ');
|
].filter((value): value is string => Boolean(value)).join(' • ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldNotifyTask(task: Task) {
|
function terminalToastDescription(entry: TaskNotificationEntry) {
|
||||||
return !task.notification_silenced_at;
|
const topStat = entry.stats[0];
|
||||||
}
|
|
||||||
|
|
||||||
function isUnread(task: Task) {
|
return [
|
||||||
return task.notification_read_at === null;
|
entry.statusLine,
|
||||||
|
topStat ? `${topStat.label}: ${topStat.value}` : null,
|
||||||
|
entry.detailLine
|
||||||
|
].filter((value): value is string => Boolean(value)).join(' • ');
|
||||||
}
|
}
|
||||||
|
|
||||||
type UseTaskNotificationsCenterResult = {
|
type UseTaskNotificationsCenterResult = {
|
||||||
activeTasks: Task[];
|
activeEntries: TaskNotificationEntry[];
|
||||||
finishedTasks: Task[];
|
finishedEntries: TaskNotificationEntry[];
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
awaitingReviewTasks: Task[];
|
awaitingReviewEntries: TaskNotificationEntry[];
|
||||||
visibleFinishedTasks: Task[];
|
visibleFinishedEntries: TaskNotificationEntry[];
|
||||||
showReadFinished: boolean;
|
showReadFinished: boolean;
|
||||||
setShowReadFinished: (value: boolean) => void;
|
setShowReadFinished: (value: boolean) => void;
|
||||||
isPopoverOpen: boolean;
|
isPopoverOpen: boolean;
|
||||||
@@ -84,9 +160,9 @@ type UseTaskNotificationsCenterResult = {
|
|||||||
isDetailOpen: boolean;
|
isDetailOpen: boolean;
|
||||||
setIsDetailOpen: (value: boolean) => void;
|
setIsDetailOpen: (value: boolean) => void;
|
||||||
openTaskDetails: (taskId: string) => void;
|
openTaskDetails: (taskId: string) => void;
|
||||||
openTaskAction: (task: Task, actionId?: string | null) => void;
|
openTaskAction: (entry: TaskNotificationEntry, actionId?: string | null) => void;
|
||||||
markTaskRead: (taskId: string, read?: boolean) => Promise<void>;
|
markEntryRead: (entry: TaskNotificationEntry, read?: boolean) => Promise<void>;
|
||||||
silenceTask: (taskId: string, silenced?: boolean) => Promise<void>;
|
silenceEntry: (entry: TaskNotificationEntry, silenced?: boolean) => Promise<void>;
|
||||||
refreshTasks: () => Promise<void>;
|
refreshTasks: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,6 +171,7 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [activeTasks, setActiveTasks] = useState<Task[]>([]);
|
const [activeTasks, setActiveTasks] = useState<Task[]>([]);
|
||||||
const [finishedTasks, setFinishedTasks] = useState<Task[]>([]);
|
const [finishedTasks, setFinishedTasks] = useState<Task[]>([]);
|
||||||
|
const [filingSyncBatch, setFilingSyncBatch] = useState<FilingSyncBatchState>(EMPTY_FILING_SYNC_BATCH_STATE);
|
||||||
const [showReadFinished, setShowReadFinished] = useState(false);
|
const [showReadFinished, setShowReadFinished] = useState(false);
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
const [hasLoadedActive, setHasLoadedActive] = useState(false);
|
const [hasLoadedActive, setHasLoadedActive] = useState(false);
|
||||||
@@ -108,6 +185,9 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
const invalidatedTerminalRef = useRef(new Set<string>());
|
const invalidatedTerminalRef = useRef(new Set<string>());
|
||||||
const activeSnapshotRef = useRef<Task[]>([]);
|
const activeSnapshotRef = useRef<Task[]>([]);
|
||||||
const finishedSnapshotRef = 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(() => {
|
const [isDocumentVisible, setIsDocumentVisible] = useState(() => {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return true;
|
return true;
|
||||||
@@ -116,9 +196,23 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
return document.visibilityState === 'visible';
|
return document.visibilityState === 'visible';
|
||||||
});
|
});
|
||||||
|
|
||||||
const applyTaskLocally = useCallback((task: Task) => {
|
const syncBatchState = useCallback((nextBatch: FilingSyncBatchState) => {
|
||||||
setActiveTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry)));
|
filingSyncBatchRef.current = nextBatch;
|
||||||
setFinishedTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry)));
|
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 invalidateForTerminalTask = useCallback((task: Task) => {
|
||||||
@@ -163,15 +257,15 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
setIsPopoverOpen(false);
|
setIsPopoverOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openTaskAction = useCallback((task: Task, actionId?: string | null) => {
|
const openTaskAction = useCallback((entry: TaskNotificationEntry, actionId?: string | null) => {
|
||||||
const action = actionId
|
const action = actionId
|
||||||
? task.notification.actions.find((entry) => entry.id === actionId)
|
? entry.actions.find((candidate) => candidate.id === actionId)
|
||||||
: task.notification.actions.find((entry) => entry.primary && entry.id !== 'open_details')
|
: entry.actions.find((candidate) => candidate.primary && candidate.id !== 'open_details')
|
||||||
?? task.notification.actions.find((entry) => entry.id !== 'open_details')
|
?? entry.actions.find((candidate) => candidate.id !== 'open_details')
|
||||||
?? null;
|
?? null;
|
||||||
|
|
||||||
if (!action || action.id === 'open_details' || !action.href) {
|
if (!action || action.id === 'open_details' || !action.href) {
|
||||||
openTaskDetails(task.id);
|
openTaskDetails(entry.primaryTaskId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,121 +273,205 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
router.push(action.href);
|
router.push(action.href);
|
||||||
}, [openTaskDetails, router]);
|
}, [openTaskDetails, router]);
|
||||||
|
|
||||||
const silenceTask = useCallback(async (taskId: string, silenced = true) => {
|
const emitEntryToast = useCallback((entry: TaskNotificationEntry) => {
|
||||||
try {
|
const toastId = toastIdForEntry(entry);
|
||||||
const { task } = await updateTaskNotificationState(taskId, { silenced });
|
|
||||||
applyTaskLocally(task);
|
|
||||||
toast.dismiss(taskId);
|
|
||||||
} catch {
|
|
||||||
toast.error('Unable to update notification state');
|
|
||||||
}
|
|
||||||
}, [applyTaskLocally]);
|
|
||||||
|
|
||||||
const markTaskRead = useCallback(async (taskId: string, read = true) => {
|
if (!shouldNotifyEntry(entry)) {
|
||||||
try {
|
toast.dismiss(toastId);
|
||||||
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);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.status === 'queued' || task.status === 'running') {
|
if (entry.status === 'queued' || entry.status === 'running') {
|
||||||
toast(taskTitle(task), {
|
toast(entry.title, {
|
||||||
id: task.id,
|
id: toastId,
|
||||||
duration: Number.POSITIVE_INFINITY,
|
duration: Number.POSITIVE_INFINITY,
|
||||||
description: taskDescription(task),
|
description: entryDescription(entry),
|
||||||
action: {
|
action: {
|
||||||
label: 'Open details',
|
label: 'Open details',
|
||||||
onClick: () => openTaskDetails(task.id)
|
onClick: () => openTaskDetails(entry.primaryTaskId)
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: {
|
||||||
label: 'Silence',
|
label: 'Silence',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
void silenceTask(task.id, true);
|
void silenceEntryRef.current(entry, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastBuilder = task.status === 'completed' ? toast.success : toast.error;
|
const toastBuilder = entry.status === 'completed' ? toast.success : toast.error;
|
||||||
const primaryAction = task.notification.actions.find((entry) => entry.primary && entry.id !== 'open_details')
|
const primaryAction = entry.actions.find((candidate) => candidate.primary && candidate.id !== 'open_details')
|
||||||
?? task.notification.actions.find((entry) => entry.id !== 'open_details')
|
?? entry.actions.find((candidate) => candidate.id !== 'open_details')
|
||||||
?? null;
|
?? null;
|
||||||
|
|
||||||
toastBuilder(taskTitle(task), {
|
toastBuilder(entry.title, {
|
||||||
id: task.id,
|
id: toastId,
|
||||||
duration: 10_000,
|
duration: 10_000,
|
||||||
description: terminalToastDescription(task),
|
description: terminalToastDescription(entry),
|
||||||
action: {
|
action: {
|
||||||
label: primaryAction?.label ?? 'Open details',
|
label: primaryAction?.label ?? 'Open details',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (primaryAction) {
|
if (primaryAction) {
|
||||||
openTaskAction(task, primaryAction.id);
|
openTaskAction(entry, primaryAction.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
openTaskDetails(task.id);
|
openTaskDetails(entry.primaryTaskId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: {
|
||||||
label: 'Mark read',
|
label: 'Mark read',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
void markTaskRead(task.id, true);
|
void markEntryReadRef.current(entry, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [markTaskRead, openTaskAction, openTaskDetails, silenceTask]);
|
}, [openTaskAction, openTaskDetails]);
|
||||||
|
|
||||||
const processSnapshots = useCallback(() => {
|
|
||||||
const active = activeSnapshotRef.current;
|
|
||||||
const finished = finishedSnapshotRef.current;
|
|
||||||
const all = [...active, ...finished];
|
|
||||||
|
|
||||||
|
const processSnapshots = useCallback((nextBatch = filingSyncBatchRef.current) => {
|
||||||
if (!activeLoadedRef.current || !finishedLoadedRef.current) {
|
if (!activeLoadedRef.current || !finishedLoadedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entries = buildNotificationEntries({
|
||||||
|
activeTasks: activeSnapshotRef.current,
|
||||||
|
finishedTasks: finishedSnapshotRef.current,
|
||||||
|
filingSyncBatch: nextBatch
|
||||||
|
});
|
||||||
|
|
||||||
if (stateSignaturesRef.current.size === 0) {
|
if (stateSignaturesRef.current.size === 0) {
|
||||||
for (const task of all) {
|
for (const entry of entries) {
|
||||||
stateSignaturesRef.current.set(task.id, taskSignature(task));
|
stateSignaturesRef.current.set(entry.id, notificationEntrySignature(entry));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const task of all) {
|
for (const entry of entries) {
|
||||||
const signature = taskSignature(task);
|
const signature = notificationEntrySignature(entry);
|
||||||
const previousSignature = stateSignaturesRef.current.get(task.id);
|
const previousSignature = stateSignaturesRef.current.get(entry.id);
|
||||||
const wasKnown = previousSignature !== undefined;
|
const wasKnown = previousSignature !== undefined;
|
||||||
|
|
||||||
if (!wasKnown || previousSignature !== signature) {
|
if (!wasKnown || previousSignature !== signature) {
|
||||||
emitTaskToast(task);
|
emitEntryToast(entry);
|
||||||
|
|
||||||
if (isTerminalTask(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);
|
invalidateForTerminalTask(task);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
stateSignaturesRef.current.set(task.id, signature);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentIds = new Set(all.map((task) => task.id));
|
stateSignaturesRef.current.set(entry.id, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIds = new Set(entries.map((entry) => entry.id));
|
||||||
for (const knownId of [...stateSignaturesRef.current.keys()]) {
|
for (const knownId of [...stateSignaturesRef.current.keys()]) {
|
||||||
if (!currentIds.has(knownId)) {
|
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 () => {
|
const refreshTasks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -308,19 +486,11 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
activeSnapshotRef.current = activeRes.tasks;
|
applySnapshotState(activeRes.tasks, finishedRes.tasks, { active: true, finished: true });
|
||||||
finishedSnapshotRef.current = finishedRes.tasks;
|
|
||||||
activeLoadedRef.current = true;
|
|
||||||
finishedLoadedRef.current = true;
|
|
||||||
setHasLoadedActive(true);
|
|
||||||
setHasLoadedFinished(true);
|
|
||||||
setActiveTasks(activeRes.tasks);
|
|
||||||
setFinishedTasks(finishedRes.tasks);
|
|
||||||
processSnapshots();
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore transient polling failures
|
// ignore transient polling failures
|
||||||
}
|
}
|
||||||
}, [processSnapshots]);
|
}, [applySnapshotState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
@@ -364,7 +534,13 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
return 4_000;
|
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;
|
return 15_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,11 +562,7 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
activeSnapshotRef.current = response.tasks;
|
applySnapshotState(response.tasks, finishedSnapshotRef.current, { active: true });
|
||||||
activeLoadedRef.current = true;
|
|
||||||
setHasLoadedActive(true);
|
|
||||||
setActiveTasks(response.tasks);
|
|
||||||
processSnapshots();
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore transient polling failures
|
// ignore transient polling failures
|
||||||
}
|
}
|
||||||
@@ -413,13 +585,27 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
finishedSnapshotRef.current = response.tasks;
|
applySnapshotState(activeSnapshotRef.current, response.tasks, { finished: true });
|
||||||
finishedLoadedRef.current = true;
|
|
||||||
setHasLoadedFinished(true);
|
const signature = response.tasks
|
||||||
setFinishedTasks(response.tasks);
|
.map((task) => notificationEntrySignature({
|
||||||
processSnapshots();
|
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) {
|
if (signature === previousTerminalSignature) {
|
||||||
stableTerminalPolls += 1;
|
stableTerminalPolls += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -445,43 +631,52 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
clearTimeout(terminalTimer);
|
clearTimeout(terminalTimer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isDetailOpen, isDocumentVisible, isPopoverOpen, processSnapshots]);
|
}, [applySnapshotState, isDetailOpen, isDocumentVisible, isPopoverOpen]);
|
||||||
|
|
||||||
const normalizedActiveTasks = useMemo(() => {
|
const entries = useMemo(() => buildNotificationEntries({
|
||||||
return activeTasks.filter((task) => ACTIVE_STATUSES.includes(task.status));
|
activeTasks,
|
||||||
}, [activeTasks]);
|
finishedTasks,
|
||||||
|
filingSyncBatch
|
||||||
|
}), [activeTasks, filingSyncBatch, finishedTasks]);
|
||||||
|
|
||||||
const normalizedFinishedTasks = useMemo(() => {
|
const activeEntries = useMemo(() => {
|
||||||
return finishedTasks.filter((task) => TERMINAL_STATUSES.includes(task.status));
|
return entries.filter((entry) => ACTIVE_STATUSES.includes(entry.status));
|
||||||
}, [finishedTasks]);
|
}, [entries]);
|
||||||
|
|
||||||
const awaitingReviewTasks = useMemo(() => {
|
const normalizedFinishedEntries = useMemo(() => {
|
||||||
return normalizedFinishedTasks.filter((task) => isUnread(task));
|
return entries.filter((entry) => TERMINAL_STATUSES.includes(entry.status));
|
||||||
}, [normalizedFinishedTasks]);
|
}, [entries]);
|
||||||
|
|
||||||
const visibleFinishedTasks = useMemo(() => {
|
const awaitingReviewEntries = useMemo(() => {
|
||||||
|
return normalizedFinishedEntries.filter((entry) => isUnreadEntry(entry));
|
||||||
|
}, [normalizedFinishedEntries]);
|
||||||
|
|
||||||
|
const visibleFinishedEntries = useMemo(() => {
|
||||||
if (showReadFinished) {
|
if (showReadFinished) {
|
||||||
return normalizedFinishedTasks;
|
return normalizedFinishedEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
return awaitingReviewTasks;
|
return awaitingReviewEntries;
|
||||||
}, [awaitingReviewTasks, normalizedFinishedTasks, showReadFinished]);
|
}, [awaitingReviewEntries, normalizedFinishedEntries, showReadFinished]);
|
||||||
|
|
||||||
const unreadCount = useMemo(() => {
|
const unreadCount = useMemo(() => {
|
||||||
const unreadTerminal = normalizedFinishedTasks.filter((task) => isUnread(task)).length;
|
const unreadTerminal = normalizedFinishedEntries.filter((entry) => isUnreadEntry(entry)).length;
|
||||||
const unreadActive = normalizedActiveTasks.filter((task) => isUnread(task) && !task.notification_silenced_at).length;
|
const unreadActive = activeEntries.filter((entry) => (
|
||||||
|
isUnreadEntry(entry) && entry.notificationSilencedAt === null
|
||||||
|
)).length;
|
||||||
|
|
||||||
return unreadTerminal + unreadActive;
|
return unreadTerminal + unreadActive;
|
||||||
}, [normalizedActiveTasks, normalizedFinishedTasks]);
|
}, [activeEntries, normalizedFinishedEntries]);
|
||||||
|
|
||||||
const isLoading = !hasLoadedActive || !hasLoadedFinished;
|
const isLoading = !hasLoadedActive || !hasLoadedFinished;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeTasks: normalizedActiveTasks,
|
activeEntries,
|
||||||
finishedTasks: normalizedFinishedTasks,
|
finishedEntries: normalizedFinishedEntries,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
isLoading,
|
isLoading,
|
||||||
awaitingReviewTasks,
|
awaitingReviewEntries,
|
||||||
visibleFinishedTasks,
|
visibleFinishedEntries,
|
||||||
showReadFinished,
|
showReadFinished,
|
||||||
setShowReadFinished,
|
setShowReadFinished,
|
||||||
isPopoverOpen,
|
isPopoverOpen,
|
||||||
@@ -492,8 +687,8 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
setIsDetailOpen,
|
setIsDetailOpen,
|
||||||
openTaskDetails,
|
openTaskDetails,
|
||||||
openTaskAction,
|
openTaskAction,
|
||||||
markTaskRead,
|
markEntryRead,
|
||||||
silenceTask,
|
silenceEntry,
|
||||||
refreshTasks
|
refreshTasks
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
import { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search';
|
import { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search';
|
||||||
import {
|
import {
|
||||||
enqueueTask,
|
enqueueTask,
|
||||||
|
findOrEnqueueTask,
|
||||||
findInFlightTask,
|
findInFlightTask,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
getTaskTimeline,
|
getTaskTimeline,
|
||||||
@@ -340,7 +341,7 @@ async function queueAutoFilingSync(
|
|||||||
metadata?: { category?: unknown; tags?: unknown }
|
metadata?: { category?: unknown; tags?: unknown }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await enqueueTask({
|
await findOrEnqueueTask({
|
||||||
userId,
|
userId,
|
||||||
taskType: 'sync_filings',
|
taskType: 'sync_filings',
|
||||||
payload: buildSyncFilingsPayload({
|
payload: buildSyncFilingsPayload({
|
||||||
@@ -1459,7 +1460,7 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
if (shouldQueueSync) {
|
if (shouldQueueSync) {
|
||||||
try {
|
try {
|
||||||
const watchlistItem = await getWatchlistItemByTicker(session.user.id, ticker);
|
const watchlistItem = await getWatchlistItemByTicker(session.user.id, ticker);
|
||||||
await enqueueTask({
|
await findOrEnqueueTask({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
taskType: 'sync_filings',
|
taskType: 'sync_filings',
|
||||||
payload: buildSyncFilingsPayload({
|
payload: buildSyncFilingsPayload({
|
||||||
@@ -1661,7 +1662,7 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const limit = typeof payload.limit === 'number' ? payload.limit : Number(payload.limit);
|
const limit = typeof payload.limit === 'number' ? payload.limit : Number(payload.limit);
|
||||||
const task = await enqueueTask({
|
const task = await findOrEnqueueTask({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
taskType: 'sync_filings',
|
taskType: 'sync_filings',
|
||||||
payload: buildSyncFilingsPayload({
|
payload: buildSyncFilingsPayload({
|
||||||
|
|||||||
@@ -467,6 +467,60 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
|
|||||||
expect(task.payload.tags).toEqual(['semis', 'ai']);
|
expect(task.payload.tags).toEqual(['semis', 'ai']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reuses the same in-flight filing sync task for repeated same-ticker requests', async () => {
|
||||||
|
const first = await jsonRequest('POST', '/api/filings/sync', {
|
||||||
|
ticker: 'NVDA',
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
const second = await jsonRequest('POST', '/api/filings/sync', {
|
||||||
|
ticker: 'nvda',
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first.response.status).toBe(200);
|
||||||
|
expect(second.response.status).toBe(200);
|
||||||
|
|
||||||
|
const firstTask = (first.json as { task: { id: string } }).task;
|
||||||
|
const secondTask = (second.json as { task: { id: string } }).task;
|
||||||
|
|
||||||
|
expect(secondTask.id).toBe(firstTask.id);
|
||||||
|
|
||||||
|
const tasksResponse = await jsonRequest('GET', '/api/tasks?limit=10&status=queued&status=running');
|
||||||
|
expect(tasksResponse.response.status).toBe(200);
|
||||||
|
|
||||||
|
const tasks = (tasksResponse.json as {
|
||||||
|
tasks: Array<{ id: string; task_type: string; payload: { ticker?: string } }>;
|
||||||
|
}).tasks.filter((task) => task.task_type === 'sync_filings' && task.payload.ticker === 'NVDA');
|
||||||
|
|
||||||
|
expect(tasks).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets different tickers queue independent filing sync tasks', async () => {
|
||||||
|
const nvda = await jsonRequest('POST', '/api/filings/sync', { ticker: 'NVDA', limit: 20 });
|
||||||
|
const msft = await jsonRequest('POST', '/api/filings/sync', { ticker: 'MSFT', limit: 20 });
|
||||||
|
const aapl = await jsonRequest('POST', '/api/filings/sync', { ticker: 'AAPL', limit: 20 });
|
||||||
|
|
||||||
|
const ids = [
|
||||||
|
(nvda.json as { task: { id: string } }).task.id,
|
||||||
|
(msft.json as { task: { id: string } }).task.id,
|
||||||
|
(aapl.json as { task: { id: string } }).task.id
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(new Set(ids).size).toBe(3);
|
||||||
|
|
||||||
|
const tasksResponse = await jsonRequest('GET', '/api/tasks?limit=10&status=queued&status=running');
|
||||||
|
expect(tasksResponse.response.status).toBe(200);
|
||||||
|
|
||||||
|
const syncTickers = (tasksResponse.json as {
|
||||||
|
tasks: Array<{ task_type: string; payload: { ticker?: string } }>;
|
||||||
|
}).tasks
|
||||||
|
.filter((task) => task.task_type === 'sync_filings')
|
||||||
|
.map((task) => task.payload.ticker)
|
||||||
|
.filter((ticker): ticker is string => typeof ticker === 'string');
|
||||||
|
|
||||||
|
expect(syncTickers.sort()).toEqual(['AAPL', 'MSFT', 'NVDA']);
|
||||||
|
});
|
||||||
|
|
||||||
it('scopes the filings endpoint by ticker while leaving the global endpoint mixed', async () => {
|
it('scopes the filings endpoint by ticker while leaving the global endpoint mixed', async () => {
|
||||||
if (!sqliteClient) {
|
if (!sqliteClient) {
|
||||||
throw new Error('sqlite client not initialized');
|
throw new Error('sqlite client not initialized');
|
||||||
|
|||||||
@@ -129,6 +129,27 @@ export async function enqueueTask(input: EnqueueTaskInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findOrEnqueueTask(input: EnqueueTaskInput) {
|
||||||
|
if (!input.resourceKey) {
|
||||||
|
return await enqueueTask(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTask = await findInFlightTaskByResourceKey(
|
||||||
|
input.userId,
|
||||||
|
input.taskType,
|
||||||
|
input.resourceKey
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingTask) {
|
||||||
|
const reconciledTask = await reconcileTaskWithWorkflow(existingTask);
|
||||||
|
if (reconciledTask.status === 'queued' || reconciledTask.status === 'running') {
|
||||||
|
return reconciledTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await enqueueTask(input);
|
||||||
|
}
|
||||||
|
|
||||||
export async function findInFlightTask(userId: string, taskType: TaskType, resourceKey: string) {
|
export async function findInFlightTask(userId: string, taskType: TaskType, resourceKey: string) {
|
||||||
const task = await findInFlightTaskByResourceKey(userId, taskType, resourceKey);
|
const task = await findInFlightTaskByResourceKey(userId, taskType, resourceKey);
|
||||||
|
|
||||||
|
|||||||
251
lib/task-notification-entries.test.ts
Normal file
251
lib/task-notification-entries.test.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { buildTaskNotification } from '@/lib/server/task-notifications';
|
||||||
|
import {
|
||||||
|
buildNotificationEntries,
|
||||||
|
notificationEntrySignature,
|
||||||
|
type FilingSyncBatchState
|
||||||
|
} from '@/lib/task-notification-entries';
|
||||||
|
import type { Task } from '@/lib/types';
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<Omit<Task, 'notification'>> = {}): Task {
|
||||||
|
const task = {
|
||||||
|
id: 'task-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
task_type: 'sync_filings',
|
||||||
|
status: 'running',
|
||||||
|
stage: 'sync.extract_taxonomy',
|
||||||
|
stage_detail: 'Extracting taxonomy for NVDA',
|
||||||
|
stage_context: {
|
||||||
|
progress: {
|
||||||
|
current: 2,
|
||||||
|
total: 5,
|
||||||
|
unit: 'filings'
|
||||||
|
},
|
||||||
|
counters: {
|
||||||
|
fetched: 5,
|
||||||
|
inserted: 2,
|
||||||
|
updated: 1,
|
||||||
|
hydrated: 1,
|
||||||
|
failed: 0
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
ticker: 'NVDA',
|
||||||
|
accessionNumber: '0000000000-26-000001'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resource_key: 'sync_filings:NVDA',
|
||||||
|
notification_read_at: null,
|
||||||
|
notification_silenced_at: null,
|
||||||
|
priority: 50,
|
||||||
|
payload: {
|
||||||
|
ticker: 'NVDA',
|
||||||
|
limit: 20
|
||||||
|
},
|
||||||
|
result: null,
|
||||||
|
error: null,
|
||||||
|
attempts: 1,
|
||||||
|
max_attempts: 3,
|
||||||
|
workflow_run_id: 'run-1',
|
||||||
|
created_at: '2026-03-14T10:00:00.000Z',
|
||||||
|
updated_at: '2026-03-14T10:05:00.000Z',
|
||||||
|
finished_at: null,
|
||||||
|
...overrides
|
||||||
|
} satisfies Omit<Task, 'notification'>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
notification: buildTaskNotification(task)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchState(overrides: Partial<FilingSyncBatchState> = {}): FilingSyncBatchState {
|
||||||
|
return {
|
||||||
|
active: true,
|
||||||
|
taskIds: ['task-1', 'task-2'],
|
||||||
|
latestTaskId: 'task-2',
|
||||||
|
startedAt: '2026-03-14T10:00:00.000Z',
|
||||||
|
finishedAt: null,
|
||||||
|
terminalVisible: false,
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('task notification entries', () => {
|
||||||
|
it('collapses multiple active filing sync tasks into one aggregate entry', () => {
|
||||||
|
const first = makeTask();
|
||||||
|
const second = makeTask({
|
||||||
|
id: 'task-2',
|
||||||
|
resource_key: 'sync_filings:MSFT',
|
||||||
|
payload: { ticker: 'MSFT', limit: 20 },
|
||||||
|
stage_context: {
|
||||||
|
progress: { current: 3, total: 5, unit: 'filings' },
|
||||||
|
counters: { fetched: 4, inserted: 1, updated: 2, hydrated: 0, failed: 0 },
|
||||||
|
subject: { ticker: 'MSFT', accessionNumber: '0000000000-26-000002' }
|
||||||
|
},
|
||||||
|
updated_at: '2026-03-14T10:06:00.000Z'
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = buildNotificationEntries({
|
||||||
|
activeTasks: [first, second],
|
||||||
|
finishedTasks: [],
|
||||||
|
filingSyncBatch: batchState()
|
||||||
|
});
|
||||||
|
|
||||||
|
const filingEntries = entries.filter((entry) => entry.kind === 'filing_sync_batch');
|
||||||
|
expect(filingEntries).toHaveLength(1);
|
||||||
|
expect(filingEntries[0]).toMatchObject({
|
||||||
|
id: 'filing-sync:active',
|
||||||
|
status: 'running',
|
||||||
|
statusLine: 'Syncing filings for 2 tickers',
|
||||||
|
detailLine: '2 running • 0 queued',
|
||||||
|
primaryTaskId: 'task-2'
|
||||||
|
});
|
||||||
|
expect(filingEntries[0]?.stats).toEqual([
|
||||||
|
{ label: 'Fetched', value: '9' },
|
||||||
|
{ label: 'Inserted', value: '3' },
|
||||||
|
{ label: 'Updated', value: '3' },
|
||||||
|
{ label: 'Hydrated', value: '1' },
|
||||||
|
{ label: 'Failed', value: '0' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds active filing sync detail from mixed queued and running tasks', () => {
|
||||||
|
const running = makeTask();
|
||||||
|
const queued = makeTask({
|
||||||
|
id: 'task-2',
|
||||||
|
status: 'queued',
|
||||||
|
stage: 'queued',
|
||||||
|
stage_detail: 'Queued for filings sync',
|
||||||
|
resource_key: 'sync_filings:AAPL',
|
||||||
|
payload: { ticker: 'AAPL', limit: 20 },
|
||||||
|
stage_context: {
|
||||||
|
progress: { current: 0, total: 5, unit: 'filings' },
|
||||||
|
counters: { fetched: 0, inserted: 0, updated: 0, hydrated: 0, failed: 0 },
|
||||||
|
subject: { ticker: 'AAPL', accessionNumber: '0000000000-26-000003' }
|
||||||
|
},
|
||||||
|
updated_at: '2026-03-14T10:04:00.000Z'
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry = buildNotificationEntries({
|
||||||
|
activeTasks: [running, queued],
|
||||||
|
finishedTasks: [],
|
||||||
|
filingSyncBatch: batchState()
|
||||||
|
}).find((candidate) => candidate.kind === 'filing_sync_batch');
|
||||||
|
|
||||||
|
expect(entry?.detailLine).toBe('1 running • 1 queued');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps one terminal filing sync summary for mixed completion outcomes', () => {
|
||||||
|
const completed = makeTask({
|
||||||
|
status: 'completed',
|
||||||
|
stage: 'completed',
|
||||||
|
stage_detail: 'Completed sync for NVDA',
|
||||||
|
result: {
|
||||||
|
ticker: 'NVDA',
|
||||||
|
fetched: 5,
|
||||||
|
inserted: 2,
|
||||||
|
updated: 1,
|
||||||
|
taxonomySnapshotsHydrated: 1,
|
||||||
|
taxonomySnapshotsFailed: 0
|
||||||
|
},
|
||||||
|
finished_at: '2026-03-14T10:07:00.000Z'
|
||||||
|
});
|
||||||
|
const failed = makeTask({
|
||||||
|
id: 'task-2',
|
||||||
|
status: 'failed',
|
||||||
|
stage: 'sync.persist_filings',
|
||||||
|
stage_detail: 'Persist failed for MSFT',
|
||||||
|
error: 'Persist failed for MSFT',
|
||||||
|
resource_key: 'sync_filings:MSFT',
|
||||||
|
payload: { ticker: 'MSFT', limit: 20 },
|
||||||
|
stage_context: {
|
||||||
|
progress: { current: 5, total: 5, unit: 'filings' },
|
||||||
|
counters: { fetched: 5, inserted: 0, updated: 0, hydrated: 0, failed: 1 },
|
||||||
|
subject: { ticker: 'MSFT', accessionNumber: '0000000000-26-000002' }
|
||||||
|
},
|
||||||
|
finished_at: '2026-03-14T10:08:00.000Z',
|
||||||
|
updated_at: '2026-03-14T10:08:00.000Z'
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry = buildNotificationEntries({
|
||||||
|
activeTasks: [],
|
||||||
|
finishedTasks: [completed, failed],
|
||||||
|
filingSyncBatch: batchState({
|
||||||
|
active: false,
|
||||||
|
terminalVisible: true,
|
||||||
|
finishedAt: '2026-03-14T10:08:00.000Z'
|
||||||
|
})
|
||||||
|
}).find((candidate) => candidate.kind === 'filing_sync_batch');
|
||||||
|
|
||||||
|
expect(entry).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
statusLine: 'Filing sync finished with issues',
|
||||||
|
detailLine: '2 tickers processed • 1 failed'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves non-sync tasks ungrouped', () => {
|
||||||
|
const sync = makeTask();
|
||||||
|
const refresh = makeTask({
|
||||||
|
id: 'task-3',
|
||||||
|
task_type: 'refresh_prices',
|
||||||
|
resource_key: 'refresh_prices:portfolio',
|
||||||
|
payload: {},
|
||||||
|
stage: 'refresh.fetch_quotes',
|
||||||
|
stage_detail: 'Fetching quotes',
|
||||||
|
stage_context: {
|
||||||
|
progress: { current: 3, total: 4, unit: 'tickers' },
|
||||||
|
counters: { updatedCount: 2, holdings: 4 },
|
||||||
|
subject: { ticker: 'NVDA' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = buildNotificationEntries({
|
||||||
|
activeTasks: [sync, refresh],
|
||||||
|
finishedTasks: [],
|
||||||
|
filingSyncBatch: batchState({ taskIds: ['task-1'] })
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries.filter((entry) => entry.kind === 'single')).toHaveLength(1);
|
||||||
|
expect(entries.some((entry) => entry.id === 'task-3')).toBe(true);
|
||||||
|
expect(entries.some((entry) => entry.kind === 'filing_sync_batch')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores noisy filing sync detail-only changes in the aggregate signature', () => {
|
||||||
|
const first = makeTask();
|
||||||
|
const second = makeTask({
|
||||||
|
id: 'task-2',
|
||||||
|
resource_key: 'sync_filings:MSFT',
|
||||||
|
payload: { ticker: 'MSFT', limit: 20 },
|
||||||
|
stage_detail: 'Extracting taxonomy for MSFT',
|
||||||
|
stage_context: {
|
||||||
|
progress: { current: 2, total: 5, unit: 'filings' },
|
||||||
|
counters: { fetched: 4, inserted: 1, updated: 2, hydrated: 0, failed: 0 },
|
||||||
|
subject: { ticker: 'MSFT', accessionNumber: '0000000000-26-000002' }
|
||||||
|
},
|
||||||
|
updated_at: '2026-03-14T10:06:00.000Z'
|
||||||
|
});
|
||||||
|
|
||||||
|
const original = buildNotificationEntries({
|
||||||
|
activeTasks: [first, second],
|
||||||
|
finishedTasks: [],
|
||||||
|
filingSyncBatch: batchState()
|
||||||
|
}).find((entry) => entry.kind === 'filing_sync_batch');
|
||||||
|
|
||||||
|
const { notification, ...secondCore } = second;
|
||||||
|
void notification;
|
||||||
|
const noisyUpdate = makeTask({
|
||||||
|
...secondCore,
|
||||||
|
stage_detail: 'Extracting taxonomy for MSFT filing 2/5'
|
||||||
|
});
|
||||||
|
const updated = buildNotificationEntries({
|
||||||
|
activeTasks: [first, noisyUpdate],
|
||||||
|
finishedTasks: [],
|
||||||
|
filingSyncBatch: batchState()
|
||||||
|
}).find((entry) => entry.kind === 'filing_sync_batch');
|
||||||
|
|
||||||
|
expect(original).toBeTruthy();
|
||||||
|
expect(updated).toBeTruthy();
|
||||||
|
expect(notificationEntrySignature(original!)).toBe(notificationEntrySignature(updated!));
|
||||||
|
});
|
||||||
|
});
|
||||||
326
lib/task-notification-entries.ts
Normal file
326
lib/task-notification-entries.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import type {
|
||||||
|
Task,
|
||||||
|
TaskNotificationAction,
|
||||||
|
TaskNotificationEntry,
|
||||||
|
TaskNotificationStat
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
|
const FILING_SYNC_ENTRY_ID = 'filing-sync:active';
|
||||||
|
const SYNC_STAT_LABELS = ['Fetched', 'Inserted', 'Updated', 'Hydrated', 'Failed'] as const;
|
||||||
|
|
||||||
|
export type FilingSyncBatchState = {
|
||||||
|
active: boolean;
|
||||||
|
taskIds: string[];
|
||||||
|
latestTaskId: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
terminalVisible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMPTY_FILING_SYNC_BATCH_STATE: FilingSyncBatchState = {
|
||||||
|
active: false,
|
||||||
|
taskIds: [],
|
||||||
|
latestTaskId: null,
|
||||||
|
startedAt: null,
|
||||||
|
finishedAt: null,
|
||||||
|
terminalVisible: false
|
||||||
|
};
|
||||||
|
|
||||||
|
function asRecord(value: unknown) {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown) {
|
||||||
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInteger(value: number) {
|
||||||
|
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInteger(value: string) {
|
||||||
|
const parsed = Number(value.replace(/,/g, ''));
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalTask(task: Task) {
|
||||||
|
return task.status === 'completed' || task.status === 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSyncTask(task: Task) {
|
||||||
|
return task.task_type === 'sync_filings';
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestTask(tasks: Task[]) {
|
||||||
|
return [...tasks].sort((left, right) => (
|
||||||
|
new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime()
|
||||||
|
))[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskTicker(task: Task) {
|
||||||
|
const payload = asRecord(task.payload);
|
||||||
|
const result = asRecord(task.result);
|
||||||
|
|
||||||
|
if (typeof task.stage_context?.subject?.ticker === 'string' && task.stage_context.subject.ticker.trim().length > 0) {
|
||||||
|
return task.stage_context.subject.ticker.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
asString(result?.ticker)
|
||||||
|
?? asString(payload?.ticker)
|
||||||
|
?? (task.resource_key?.startsWith('sync_filings:') ? task.resource_key.slice('sync_filings:'.length) : null)
|
||||||
|
)?.toUpperCase() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskNotificationEntry(task: Task): TaskNotificationEntry {
|
||||||
|
return {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumStats(tasks: Task[]) {
|
||||||
|
return SYNC_STAT_LABELS.flatMap((label) => {
|
||||||
|
let seen = false;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const stat = task.notification.stats.find((entry) => entry.label === label);
|
||||||
|
if (!stat) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseInteger(stat.value);
|
||||||
|
if (parsed === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen = true;
|
||||||
|
total += parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return seen ? [{ label, value: formatInteger(total) } satisfies TaskNotificationStat] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateProgress(tasks: Task[]) {
|
||||||
|
const progressEntries = tasks
|
||||||
|
.map((task) => task.notification.progress)
|
||||||
|
.filter((progress): progress is NonNullable<Task['notification']['progress']> => Boolean(progress));
|
||||||
|
|
||||||
|
if (progressEntries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = progressEntries[0]?.unit ?? null;
|
||||||
|
if (!unit || progressEntries.some((progress) => progress.unit !== unit)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = progressEntries.reduce((sum, progress) => sum + progress.current, 0);
|
||||||
|
const total = progressEntries.reduce((sum, progress) => sum + progress.total, 0);
|
||||||
|
const percent = total > 0 ? Math.min(100, Math.max(0, Math.round((current / total) * 100))) : null;
|
||||||
|
|
||||||
|
return { current, total, unit, percent };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilingActions(tasks: Task[]): TaskNotificationAction[] {
|
||||||
|
const tickers = [...new Set(tasks.map((task) => taskTicker(task)).filter((ticker): ticker is string => Boolean(ticker)))];
|
||||||
|
const href = tickers.length === 1
|
||||||
|
? `/filings?ticker=${encodeURIComponent(tickers[0] ?? '')}`
|
||||||
|
: '/filings';
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'open_filings',
|
||||||
|
label: 'Open filings',
|
||||||
|
href,
|
||||||
|
primary: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open_details',
|
||||||
|
label: 'Open details',
|
||||||
|
href: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateNotificationState(tasks: Task[]) {
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
return {
|
||||||
|
notificationReadAt: null,
|
||||||
|
notificationSilencedAt: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
notificationReadAt: tasks.every((task) => task.notification_read_at !== null)
|
||||||
|
? latestTask(tasks)?.notification_read_at ?? null
|
||||||
|
: null,
|
||||||
|
notificationSilencedAt: tasks.every((task) => task.notification_silenced_at !== null)
|
||||||
|
? latestTask(tasks)?.notification_silenced_at ?? null
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActiveFilingSyncEntry(activeTasks: Task[], memberTasks: Task[], batch: FilingSyncBatchState) {
|
||||||
|
const sourceTasks = memberTasks.length > 0 ? memberTasks : activeTasks;
|
||||||
|
const latest = latestTask(activeTasks) ?? latestTask(sourceTasks);
|
||||||
|
if (!latest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickers = [...new Set(sourceTasks.map((task) => taskTicker(task)).filter((ticker): ticker is string => Boolean(ticker)))];
|
||||||
|
const runningCount = activeTasks.filter((task) => task.status === 'running').length;
|
||||||
|
const queuedCount = activeTasks.filter((task) => task.status === 'queued').length;
|
||||||
|
const notificationState = aggregateNotificationState(sourceTasks);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: FILING_SYNC_ENTRY_ID,
|
||||||
|
kind: 'filing_sync_batch',
|
||||||
|
status: runningCount > 0 ? 'running' : 'queued',
|
||||||
|
title: 'Filing sync',
|
||||||
|
statusLine: `Syncing filings for ${tickers.length || activeTasks.length} ${tickers.length === 1 || activeTasks.length === 1 ? 'ticker' : 'tickers'}`,
|
||||||
|
detailLine: tickers.length === 1 && latest.notification.detailLine
|
||||||
|
? latest.notification.detailLine
|
||||||
|
: `${runningCount} running • ${queuedCount} queued`,
|
||||||
|
progress: aggregateProgress(sourceTasks),
|
||||||
|
stats: sumStats(sourceTasks),
|
||||||
|
updatedAt: latest.updated_at,
|
||||||
|
primaryTaskId: batch.latestTaskId ?? latest.id,
|
||||||
|
taskIds: batch.taskIds.length > 0 ? batch.taskIds : sourceTasks.map((task) => task.id),
|
||||||
|
actions: buildFilingActions(sourceTasks),
|
||||||
|
notificationReadAt: notificationState.notificationReadAt,
|
||||||
|
notificationSilencedAt: notificationState.notificationSilencedAt,
|
||||||
|
meta: {
|
||||||
|
tickerCount: tickers.length || activeTasks.length,
|
||||||
|
runningCount,
|
||||||
|
queuedCount,
|
||||||
|
failureCount: 0
|
||||||
|
}
|
||||||
|
} satisfies TaskNotificationEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTerminalFilingSyncEntry(tasks: Task[], batch: FilingSyncBatchState) {
|
||||||
|
if (!batch.terminalVisible || tasks.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = latestTask(tasks);
|
||||||
|
if (!latest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickers = [...new Set(tasks.map((task) => taskTicker(task)).filter((ticker): ticker is string => Boolean(ticker)))];
|
||||||
|
const failureCount = tasks.filter((task) => task.status === 'failed').length;
|
||||||
|
const notificationState = aggregateNotificationState(tasks);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: FILING_SYNC_ENTRY_ID,
|
||||||
|
kind: 'filing_sync_batch',
|
||||||
|
status: failureCount > 0 ? 'failed' : 'completed',
|
||||||
|
title: 'Filing sync',
|
||||||
|
statusLine: failureCount > 0 ? 'Filing sync finished with issues' : 'Finished syncing filings',
|
||||||
|
detailLine: `${tickers.length || tasks.length} ${tickers.length === 1 || tasks.length === 1 ? 'ticker' : 'tickers'} processed${failureCount > 0 ? ` • ${failureCount} failed` : ''}`,
|
||||||
|
progress: aggregateProgress(tasks),
|
||||||
|
stats: sumStats(tasks),
|
||||||
|
updatedAt: batch.finishedAt ?? latest.updated_at,
|
||||||
|
primaryTaskId: batch.latestTaskId ?? latest.id,
|
||||||
|
taskIds: batch.taskIds.length > 0 ? batch.taskIds : tasks.map((task) => task.id),
|
||||||
|
actions: buildFilingActions(tasks),
|
||||||
|
notificationReadAt: notificationState.notificationReadAt,
|
||||||
|
notificationSilencedAt: notificationState.notificationSilencedAt,
|
||||||
|
meta: {
|
||||||
|
tickerCount: tickers.length || tasks.length,
|
||||||
|
runningCount: 0,
|
||||||
|
queuedCount: 0,
|
||||||
|
failureCount
|
||||||
|
}
|
||||||
|
} satisfies TaskNotificationEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNotificationEntries(input: {
|
||||||
|
activeTasks: Task[];
|
||||||
|
finishedTasks: Task[];
|
||||||
|
filingSyncBatch: FilingSyncBatchState;
|
||||||
|
}) {
|
||||||
|
const entries: TaskNotificationEntry[] = [];
|
||||||
|
const batchTaskIds = new Set(input.filingSyncBatch.taskIds);
|
||||||
|
|
||||||
|
for (const task of input.activeTasks) {
|
||||||
|
if (!isSyncTask(task)) {
|
||||||
|
entries.push(taskNotificationEntry(task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of input.finishedTasks) {
|
||||||
|
if (!isSyncTask(task)) {
|
||||||
|
entries.push(taskNotificationEntry(task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSyncTasks = input.activeTasks.filter(isSyncTask);
|
||||||
|
const knownBatchSyncTasks = batchTaskIds.size > 0
|
||||||
|
? [...input.activeTasks, ...input.finishedTasks].filter((task) => isSyncTask(task) && batchTaskIds.has(task.id))
|
||||||
|
: activeSyncTasks;
|
||||||
|
|
||||||
|
const filingEntry = activeSyncTasks.length > 0
|
||||||
|
? buildActiveFilingSyncEntry(activeSyncTasks, knownBatchSyncTasks, input.filingSyncBatch)
|
||||||
|
: buildTerminalFilingSyncEntry(knownBatchSyncTasks.filter(isTerminalTask), input.filingSyncBatch);
|
||||||
|
|
||||||
|
if (filingEntry) {
|
||||||
|
entries.push(filingEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.sort((left, right) => (
|
||||||
|
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFilingSyncEntry(entry: TaskNotificationEntry) {
|
||||||
|
return entry.kind === 'filing_sync_batch';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationEntrySignature(entry: TaskNotificationEntry) {
|
||||||
|
if (!isFilingSyncEntry(entry)) {
|
||||||
|
return JSON.stringify({
|
||||||
|
kind: entry.kind,
|
||||||
|
status: entry.status,
|
||||||
|
statusLine: entry.statusLine,
|
||||||
|
detailLine: entry.detailLine,
|
||||||
|
progress: entry.progress,
|
||||||
|
stats: entry.stats,
|
||||||
|
primaryTaskId: entry.primaryTaskId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressBucket = entry.progress?.percent === null || entry.progress?.percent === undefined
|
||||||
|
? null
|
||||||
|
: Math.floor(entry.progress.percent / 10) * 10;
|
||||||
|
const primaryAction = entry.actions.find((action) => action.primary && action.href) ?? null;
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
kind: entry.kind,
|
||||||
|
status: entry.status,
|
||||||
|
progressBucket,
|
||||||
|
runningCount: entry.meta?.runningCount ?? 0,
|
||||||
|
queuedCount: entry.meta?.queuedCount ?? 0,
|
||||||
|
failureCount: entry.meta?.failureCount ?? 0,
|
||||||
|
primaryTaskId: entry.primaryTaskId,
|
||||||
|
primaryHref: primaryAction?.href ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
23
lib/types.ts
23
lib/types.ts
@@ -199,6 +199,29 @@ export type TaskNotificationView = {
|
|||||||
actions: TaskNotificationAction[];
|
actions: TaskNotificationAction[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TaskNotificationEntry = {
|
||||||
|
id: string;
|
||||||
|
kind: 'single' | 'filing_sync_batch';
|
||||||
|
status: TaskStatus;
|
||||||
|
title: string;
|
||||||
|
statusLine: string;
|
||||||
|
detailLine: string | null;
|
||||||
|
progress: TaskNotificationView['progress'];
|
||||||
|
stats: TaskNotificationStat[];
|
||||||
|
updatedAt: string;
|
||||||
|
primaryTaskId: string;
|
||||||
|
taskIds: string[];
|
||||||
|
actions: TaskNotificationAction[];
|
||||||
|
notificationReadAt: string | null;
|
||||||
|
notificationSilencedAt: string | null;
|
||||||
|
meta?: {
|
||||||
|
tickerCount: number;
|
||||||
|
runningCount: number;
|
||||||
|
queuedCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type Task = {
|
export type Task = {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user