'use client';
import { format } from 'date-fns';
import { ChevronDown, LoaderCircle, X } from 'lucide-react';
import Link from 'next/link';
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';
import { Button } from '@/components/ui/button';
import type { Task, TaskStageContext } from '@/lib/types';
function formatTimestamp(value: string | null) {
if (!value) {
return 'n/a';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return 'n/a';
}
return format(parsed, 'MMM dd, yyyy HH:mm:ss');
}
function formatCounterLabel(value: string) {
return value
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function ProgressPanel({ task }: { task: Task }) {
const progress = task.notification.progress;
if (!progress) {
return null;
}
return (
{progress.current}/{progress.total} {progress.unit}
{progress.percent ?? 0}%
);
}
function StatsPanel({ task }: { task: Task }) {
const counters = task.stage_context?.counters ?? {};
const counterEntries = Object.entries(counters);
if (task.notification.stats.length === 0 && counterEntries.length === 0) {
return No structured metrics available for this job yet.
;
}
return (
{task.notification.stats.map((stat) => (
{stat.label} {stat.value}
))}
{counterEntries.map(([label, value]) => (
{formatCounterLabel(label)} {value}
))}
);
}
function StageContextBlock({ context }: { context: TaskStageContext | null }) {
if (!context) {
return null;
}
const counters = Object.entries(context.counters ?? {});
return (
{context.progress ? (
Progress {context.progress.current}/{context.progress.total} {context.progress.unit}
) : null}
{context.subject ? (
{[context.subject.ticker, context.subject.accessionNumber, context.subject.label].filter(Boolean).join(' ยท ')}
) : null}
{counters.length > 0 ? (
{counters.map(([label, value]) => (
{formatCounterLabel(label)}: {value}
))}
) : null}
);
}
type TaskDetailModalProps = {
isOpen: boolean;
taskId: string | null;
onClose: () => void;
};
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') {
return null;
}
if (task?.status === 'failed') {
return task.stage;
}
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;
}
return (
Job details
{task ? taskTypeLabel(task.task_type) : 'Task'}
{taskId}
{task ? : null}
{isLoading ? (
Loading task timeline...
{Array.from({ length: 4 }).map((_, index) => (
))}
) : null}
{error ? (
Unable to load task timeline.
) : null}
{task ? (
<>
Stage: {stageLabel(task.stage)}
Workflow run: {task.workflow_run_id ?? 'n/a'}
Created: {formatTimestamp(task.created_at)}
Finished: {formatTimestamp(task.finished_at)}
Updated: {formatTimestamp(task.updated_at)}
Attempts: {task.attempts}/{task.max_attempts}
Summary
{task.notification.title}
{task.notification.statusLine}
{task.notification.detailLine ? (
{task.notification.detailLine}
) : null}
{task.notification.actions
.filter((action) => action.id !== 'open_details' && action.href)
.map((action) => (
{action.label}
))}
Stage timeline
{timeline.map((item) => (
-
{expandedStage === item.stage ? (
{item.detail ?? 'No additional detail for this step.'}
) : null}
))}
{task.error ? (
) : null}
{task.result ? (
Debug result
{JSON.stringify(task.result, null, 2)}
) : null}
>
) : null}
);
}