From 706c763dc491ce2ea032f1bc43c85a70eed0223a Mon Sep 17 00:00:00 2001 From: francy51 Date: Mon, 2 Mar 2026 21:26:08 -0500 Subject: [PATCH] refactor: move notifications to popover and simplify task timeline --- .../notifications/task-detail-modal.tsx | 185 ++++++++++----- .../task-notifications-drawer.tsx | 181 -------------- .../task-notifications-trigger.tsx | 223 ++++++++++-------- components/shell/app-shell.tsx | 56 ++--- hooks/use-task-notifications-center.ts | 16 +- 5 files changed, 274 insertions(+), 387 deletions(-) delete mode 100644 components/notifications/task-notifications-drawer.tsx diff --git a/components/notifications/task-detail-modal.tsx b/components/notifications/task-detail-modal.tsx index 065c5c8..7e0579c 100644 --- a/components/notifications/task-detail-modal.tsx +++ b/components/notifications/task-detail-modal.tsx @@ -1,7 +1,8 @@ 'use client'; import { format } from 'date-fns'; -import { LoaderCircle, X } from 'lucide-react'; +import { ChevronDown, LoaderCircle, X } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; import { useTaskTimelineQuery } from '@/hooks/use-api-queries'; import { buildStageTimeline, stageLabel, taskTypeLabel } from '@/components/notifications/task-stage-helpers'; import { StatusPill } from '@/components/ui/status-pill'; @@ -28,15 +29,50 @@ type TaskDetailModalProps = { export function TaskDetailModal({ isOpen, taskId, onClose }: TaskDetailModalProps) { const { data, isLoading, error } = useTaskTimelineQuery(taskId ?? '', isOpen && Boolean(taskId)); + const task = data?.task ?? null; + const events = data?.events ?? []; + const timeline = task ? buildStageTimeline(task, events) : []; + const [expandedStage, setExpandedStage] = useState(null); + + const defaultExpandedStage = useMemo(() => { + if (task?.status === 'completed' || task?.status === 'failed') { + return null; + } + + for (const item of timeline) { + if (item.state === 'active') { + return item.stage; + } + } + + for (let index = timeline.length - 1; index >= 0; index -= 1) { + if (timeline[index]?.state === 'completed') { + return timeline[index].stage; + } + } + + return timeline[0]?.stage ?? null; + }, [timeline]); + + useEffect(() => { + if (!isOpen) { + setExpandedStage(null); + return; + } + + setExpandedStage((current) => { + if (current && timeline.some((item) => item.stage === current)) { + return current; + } + + return defaultExpandedStage; + }); + }, [defaultExpandedStage, isOpen, timeline]); if (!isOpen || !taskId) { return null; } - const task = data?.task ?? null; - const events = data?.events ?? []; - const timeline = task ? buildStageTimeline(task, events) : []; - return (
+ + {expandedStage === item.stage ? ( +
+

{item.detail ?? 'No additional detail for this step.'}

+ {item.stage === 'completed' && task.result ? ( +
+

Result detail

+
{JSON.stringify(task.result, null, 2)}
+
+ ) : null} +
+ ) : null} + + ))} + +
+ + {task.error ? ( +
+

Error

+

{task.error}

+
+ ) : null} + + ) : null} +
diff --git a/components/notifications/task-notifications-drawer.tsx b/components/notifications/task-notifications-drawer.tsx deleted file mode 100644 index 8d13570..0000000 --- a/components/notifications/task-notifications-drawer.tsx +++ /dev/null @@ -1,181 +0,0 @@ -'use client'; - -import { formatDistanceToNow } from 'date-fns'; -import { BellOff, CheckCheck, EyeOff, X } from 'lucide-react'; -import type { Task } from '@/lib/types'; -import { taskTypeLabel } from '@/components/notifications/task-stage-helpers'; -import { Button } from '@/components/ui/button'; -import { StatusPill } from '@/components/ui/status-pill'; - -type TaskNotificationsDrawerProps = { - isOpen: boolean; - onClose: () => void; - activeTasks: Task[]; - visibleFinishedTasks: Task[]; - awaitingReviewTasks: Task[]; - showReadFinished: boolean; - setShowReadFinished: (value: boolean) => void; - openTaskDetails: (taskId: string) => void; - markTaskRead: (taskId: string, read?: boolean) => Promise; - silenceTask: (taskId: string, silenced?: boolean) => Promise; -}; - -function TaskRow({ - task, - openTaskDetails, - markTaskRead, - silenceTask -}: { - task: Task; - openTaskDetails: (taskId: string) => void; - markTaskRead: (taskId: string, read?: boolean) => Promise; - silenceTask: (taskId: string, silenced?: boolean) => Promise; -}) { - const isTerminal = task.status === 'completed' || task.status === 'failed'; - const isRead = task.notification_read_at !== null; - - return ( -
-
-
-

{taskTypeLabel(task.task_type)}

-

{task.stage_detail ?? task.stage}

-

- Updated {formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })} -

-
- -
- -
- - - {isTerminal ? ( - - ) : ( - - )} -
-
- ); -} - -export function TaskNotificationsDrawer({ - isOpen, - onClose, - activeTasks, - visibleFinishedTasks, - awaitingReviewTasks, - showReadFinished, - setShowReadFinished, - openTaskDetails, - markTaskRead, - silenceTask -}: TaskNotificationsDrawerProps) { - if (!isOpen) { - return null; - } - - return ( -
- - - -
- -

Unread finished: {awaitingReviewTasks.length}

-
- -
-

Active jobs

-
- {activeTasks.length === 0 ? ( -

No active jobs.

- ) : ( - activeTasks.map((task) => ( - - )) - )} -
-
- -
-

Awaiting review

-
- {visibleFinishedTasks.length === 0 ? ( -

No finished jobs to review.

- ) : ( - visibleFinishedTasks.map((task) => ( - - )) - )} -
-
- -
- ); -} diff --git a/components/notifications/task-notifications-trigger.tsx b/components/notifications/task-notifications-trigger.tsx index 04affce..cf8150c 100644 --- a/components/notifications/task-notifications-trigger.tsx +++ b/components/notifications/task-notifications-trigger.tsx @@ -1,9 +1,8 @@ 'use client'; import { formatDistanceToNow } from 'date-fns'; -import { Bell, BellRing, ChevronRight } from 'lucide-react'; +import { Bell, BellRing, LoaderCircle } from 'lucide-react'; import type { Task } from '@/lib/types'; -import { Button } from '@/components/ui/button'; import { StatusPill } from '@/components/ui/status-pill'; import { taskTypeLabel } from '@/components/notifications/task-stage-helpers'; import { cn } from '@/lib/utils'; @@ -12,9 +11,12 @@ type TaskNotificationsTriggerProps = { unreadCount: number; isPopoverOpen: boolean; setIsPopoverOpen: (value: boolean) => void; + isLoading: boolean; activeTasks: Task[]; + visibleFinishedTasks: Task[]; awaitingReviewTasks: Task[]; - openDrawer: () => void; + showReadFinished: boolean; + setShowReadFinished: (value: boolean) => void; openTaskDetails: (taskId: string) => void; silenceTask: (taskId: string, silenced?: boolean) => Promise; markTaskRead: (taskId: string, read?: boolean) => Promise; @@ -26,29 +28,23 @@ export function TaskNotificationsTrigger({ unreadCount, isPopoverOpen, setIsPopoverOpen, + isLoading, activeTasks, + visibleFinishedTasks, awaitingReviewTasks, - openDrawer, + showReadFinished, + setShowReadFinished, openTaskDetails, silenceTask, markTaskRead, className, mobile = false }: TaskNotificationsTriggerProps) { - const showPopover = !mobile; - const button = ( ); - if (!showPopover) { - return button; - } - return (
{button} @@ -83,95 +75,126 @@ export function TaskNotificationsTrigger({ className="fixed inset-0 z-40 cursor-default bg-transparent" onClick={() => setIsPopoverOpen(false)} /> -
+

Job notifications

{unreadCount} unread
+ +
Unread finished: {awaitingReviewTasks.length}
-
-

Active

- {activeTasks.length === 0 ? ( -

No active jobs.

- ) : ( - activeTasks.slice(0, 3).map((task) => ( -
-
-

{taskTypeLabel(task.task_type)}

- -
-

{task.stage_detail ?? 'Running in workflow engine.'}

-
- - -
-
- )) - )} -
+ {isLoading ? ( +
+
+ + Loading notifications... +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+ ))} +
+ ) : ( +
+
+

Active jobs

+ {activeTasks.length === 0 ? ( +

No active jobs.

+ ) : ( + activeTasks.map((task) => ( +
+
+

{taskTypeLabel(task.task_type)}

+ +
+

{task.stage_detail ?? 'Running in workflow engine.'}

+

+ {formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })} +

+
+ + +
+
+ )) + )} +
-
-

Awaiting review

- {awaitingReviewTasks.length === 0 ? ( -

No unread finished jobs.

- ) : ( - awaitingReviewTasks.slice(0, 3).map((task) => ( -
-
-

{taskTypeLabel(task.task_type)}

- -
-

- {formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })} -

-
- - -
-
- )) - )} -
+
+

Awaiting review

+ {visibleFinishedTasks.length === 0 ? ( +

No finished jobs to review.

+ ) : ( + visibleFinishedTasks.map((task) => { + const isRead = task.notification_read_at !== null; - + return ( +
+
+

{taskTypeLabel(task.task_type)}

+ +
+

{task.stage_detail ?? task.stage}

+

+ {formatDistanceToNow(new Date(task.updated_at), { addSuffix: true })} +

+
+ + +
+
+ ); + }) + )} +
+
+ )}
) : null} diff --git a/components/shell/app-shell.tsx b/components/shell/app-shell.tsx index 786a953..a937653 100644 --- a/components/shell/app-shell.tsx +++ b/components/shell/app-shell.tsx @@ -8,7 +8,6 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; import { authClient } from '@/lib/auth-client'; import { TaskDetailModal } from '@/components/notifications/task-detail-modal'; -import { TaskNotificationsDrawer } from '@/components/notifications/task-notifications-drawer'; import { TaskNotificationsTrigger } from '@/components/notifications/task-notifications-trigger'; import { companyAnalysisQueryOptions, @@ -422,7 +421,23 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
-
+
+
+ +

Live System

@@ -432,17 +447,6 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs, ) : null}
- notifications.setIsDrawerOpen(true)} - openTaskDetails={notifications.openTaskDetails} - silenceTask={notifications.silenceTask} - markTaskRead={notifications.markTaskRead} - /> {actions}