import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { TaskEvent } from '../../entities/TaskEvent'; import { getTaskTimeline, getEventTypeLabel, getPriorityLabel, } from '../../utils/taskEventService'; import { ClockIcon, ExclamationTriangleIcon, SparklesIcon, } from '@heroicons/react/24/outline'; interface TaskTimelineProps { taskId: number | undefined; refreshKey?: number; } const TaskTimeline: React.FC = ({ taskId, refreshKey }) => { const { t } = useTranslation(); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchTimeline = async () => { if (!taskId || taskId === undefined) { setLoading(false); setEvents([]); return; } setLoading(true); setError(null); try { const timeline = await getTaskTimeline(taskId); // Sort events by created_at in descending order (most recent first) const sortedTimeline = timeline.sort( (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ); // Show all events, scrolling will handle display setEvents(sortedTimeline); } catch (err) { console.error('Error fetching task timeline:', err); setError(t('timeline.failedToLoad', 'Failed to load timeline')); } finally { setLoading(false); } }; fetchTimeline(); }, [taskId, refreshKey]); const getTranslatedStatusLabel = (status: number | string): string => { // Handle both numeric and string status values const statusMap: Record = { // Numeric values 0: t('status.notStarted'), 1: t('status.inProgress'), 2: t('status.completed'), 3: t('status.archived'), 4: t('status.waiting'), // String values not_started: t('status.notStarted'), in_progress: t('status.inProgress'), done: t('status.completed'), completed: t('status.completed'), archived: t('status.archived'), waiting: t('status.waiting'), }; return statusMap[status] || t('status.unknown', { status }); }; const getEventDescription = (event: TaskEvent) => { const { event_type, old_value, new_value } = event; switch (event_type) { case 'created': return t('timeline.events.taskCreated'); case 'status_changed': case 'completed': { const oldStatus = old_value?.status; const newStatus = new_value?.status; if (oldStatus !== undefined && newStatus !== undefined) { return `${t('timeline.events.status')}: ${getTranslatedStatusLabel(oldStatus)} → ${getTranslatedStatusLabel(newStatus)}`; } return t('timeline.events.statusChanged'); } case 'priority_changed': { const oldPriority = old_value?.priority; const newPriority = new_value?.priority; if (oldPriority !== undefined && newPriority !== undefined) { return `${t('timeline.events.priority')}: ${getPriorityLabel(oldPriority)} → ${getPriorityLabel(newPriority)}`; } return t('timeline.events.priorityChanged'); } case 'due_date_changed': { const oldDate = old_value?.due_date; const newDate = new_value?.due_date; if (oldDate || newDate) { return `${t('timeline.events.dueDate')}: ${formatDate(oldDate)} → ${formatDate(newDate)}`; } return t('timeline.events.dueDateChanged'); } case 'recurrence_end_date_changed': { const oldDate = old_value?.recurrence_end_date; const newDate = new_value?.recurrence_end_date; if (oldDate || newDate) { return `${t('timeline.events.recurrenceEndDate')}: ${formatDate(oldDate)} → ${formatDate(newDate)}`; } return t('timeline.events.recurrenceEndDateChanged'); } case 'name_changed': return t('timeline.events.nameUpdated'); case 'description_changed': return t('timeline.events.descriptionUpdated'); case 'note_changed': return t('timeline.events.noteUpdated'); case 'project_changed': return t('timeline.events.projectChanged'); case 'tags_changed': return t('timeline.events.tagsUpdated'); case 'archived': return t('timeline.events.taskArchived'); case 'today_changed': return t('timeline.events.todayFlagChanged'); default: return getEventTypeLabel(event_type); } }; const formatDate = (dateString: string | null) => { if (!dateString) return t('timeline.events.none'); // Handle ISO date strings (e.g., "2025-07-15T00:00:00.000Z") const date = new Date(dateString); // Check if it's today, tomorrow, or yesterday const today = new Date().toISOString().split('T')[0]; const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000) .toISOString() .split('T')[0]; const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) .toISOString() .split('T')[0]; const dateOnly = date.toISOString().split('T')[0]; if (dateOnly === today) return t('dateIndicators.today'); if (dateOnly === tomorrow) return t('dateIndicators.tomorrow'); if (dateOnly === yesterday) return t('dateIndicators.yesterday'); // Return formatted date (e.g., "Jul 15, 2025") return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); }; const formatTimeAgo = (dateString: string) => { const date = new Date(dateString); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMinutes = Math.floor(diffMs / (1000 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffMinutes < 1) return 'Just now'; if (diffMinutes < 60) return `${diffMinutes}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); }; if (loading) { return (
Loading timeline...
); } if (error) { return (
{error}
); } if (!taskId) { return (
Timeline will appear after saving
); } if (events.length === 0) { return (
{t('task.noActivityYet', 'No activity yet')}
); } return (
{events.map((event) => (
{/* Event item */}
{/* Content */}
{getEventDescription(event)}
{formatTimeAgo(event.created_at)}
{/* Additional details for certain events */} {event.event_type === 'tags_changed' && event.new_value && (
{Array.isArray(event.new_value) && event.new_value.map( ( tag: any, tagIndex: number ) => ( {tag.name || tag} ) )}
)}
))}
); }; export default TaskTimeline;