import { useAppTranslation } from '@features/localization/renderer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; import { REVIEW_STATE_DISPLAY, TASK_STATUS_LABELS, TASK_STATUS_STYLES, } from '@renderer/utils/memberHelpers'; import { calculateTaskImplementationEventDuration, formatTaskImplementationDuration, } from '@shared/utils/taskWorkDuration'; import { ArrowRight, Eye, MessageSquareX, Plus, ShieldCheck, UserRound } from 'lucide-react'; import type { TaskHistoryEvent, TaskWorkInterval, TeamReviewState, TeamTaskStatus, } from '@shared/types'; interface WorkflowTimelineProps { events: TaskHistoryEvent[]; /** Map of member name → color name for colored badges. */ memberColorMap?: Map; implementationDurationTask?: { status?: string | null; workIntervals?: TaskWorkInterval[] | null; } | null; nowMs?: number; } export const WorkflowTimeline = ({ events, memberColorMap, implementationDurationTask, nowMs, }: WorkflowTimelineProps): React.JSX.Element => { const { t } = useAppTranslation('team'); const implementationNowMs = nowMs ?? 0; if (events.length === 0) { return (
{t('taskDetail.workflowTimeline.empty')}
); } return (
{events.map((event, idx) => { const isLast = idx === events.length - 1; const time = formatTime(event.timestamp); const implementationDuration = implementationDurationTask ? calculateTaskImplementationEventDuration( implementationDurationTask, event, implementationNowMs ) : null; return (
{/* Timeline line + dot */}
{!isLast &&
}
{/* Content */}
{time} {implementationDuration ? ( {implementationDuration.running ? t('taskDetail.workflowTimeline.runningPrefix') : ''} {formatTaskImplementationDuration(implementationDuration.elapsedMs)} ) : null} {shouldShowTrailingActor(event) && event.actor ? ( ) : null}
{new Date(event.timestamp).toLocaleString()}
); })}
); }; /** Keep old name as re-export for backwards compatibility during migration. */ export const StatusHistoryTimeline = WorkflowTimeline; const EventContent = ({ event, memberColorMap, }: { event: TaskHistoryEvent; memberColorMap?: Map; }): React.JSX.Element => { const { t } = useAppTranslation('team'); switch (event.type) { case 'task_created': return ( {t('taskDetail.workflowTimeline.createdAs')} {event.actor ? ( <> {t('taskDetail.workflowTimeline.by')} ) : null} ); case 'status_changed': return ( ); case 'owner_changed': return ( {event.from && event.to ? ( <> {t('taskDetail.workflowTimeline.reassigned')} ) : event.to ? ( <> {t('taskDetail.workflowTimeline.assignedTo')} ) : event.from ? ( <> {t('taskDetail.workflowTimeline.unassignedFrom')} ) : ( t('taskDetail.workflowTimeline.ownerChanged') )} ); case 'review_requested': return ( {t('taskDetail.workflowTimeline.reviewRequested')} {event.reviewer ? ( ) : null} ); case 'review_started': return ( {t('taskDetail.workflowTimeline.reviewStarted')} ); case 'review_changes_requested': return ( {t('taskDetail.workflowTimeline.changesRequested')} ); case 'review_approved': return ( {t('taskDetail.workflowTimeline.approved')} ); default: return {t('taskDetail.workflowTimeline.unknownEvent')}; } }; const StatusBadge = ({ status }: { status: TeamTaskStatus }): React.JSX.Element => { const style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending; const { t } = useAppTranslation('team'); const label = TASK_STATUS_LABELS[status] ? getStatusLabel(status, t) : status; return ( {label} ); }; const ReviewStateBadge = ({ state }: { state: TeamReviewState }): React.JSX.Element | null => { const { t } = useAppTranslation('team'); if (state === 'none') return null; const display = REVIEW_STATE_DISPLAY[state]; if (!display) return null; return ( {getReviewStateLabel(state, t)} ); }; function getStatusLabel( status: TeamTaskStatus, t: ReturnType['t'] ): string { switch (status) { case 'pending': return t('tasks.status.pending'); case 'in_progress': return t('tasks.status.inProgress'); case 'completed': return t('tasks.status.completed'); case 'deleted': return t('tasks.status.deleted'); default: return status; } } function getReviewStateLabel( state: TeamReviewState, t: ReturnType['t'] ): string { switch (state) { case 'approved': return t('taskDetail.reviewStates.approved'); case 'needsFix': return t('taskDetail.reviewStates.needsFix'); case 'review': return t('taskDetail.reviewStates.inReview'); case 'none': return ''; default: return state; } } function dotColor(event: TaskHistoryEvent): string { switch (event.type) { case 'task_created': return dotColorForStatus(event.status); case 'status_changed': return dotColorForStatus(event.to); case 'owner_changed': return 'bg-cyan-400'; case 'review_requested': return 'bg-purple-400'; case 'review_started': return 'bg-purple-400'; case 'review_changes_requested': return 'bg-amber-400'; case 'review_approved': return 'bg-emerald-400'; default: return 'bg-zinc-500'; } } function shouldShowTrailingActor(event: TaskHistoryEvent): boolean { return event.type !== 'task_created'; } function dotColorForStatus(status: TeamTaskStatus): string { switch (status) { case 'pending': return 'bg-zinc-500'; case 'in_progress': return 'bg-blue-400'; case 'completed': return 'bg-emerald-400'; case 'deleted': return 'bg-red-400'; default: return 'bg-zinc-500'; } } function formatTime(timestamp: string): string { try { const date = new Date(timestamp); if (isNaN(date.getTime())) return '??:??'; return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); } catch { return '??:??'; } }