import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { cn } from '@renderer/lib/utils'; import { clearTaskManualUnread } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; import { projectColor } from '@renderer/utils/projectColor'; import { projectLabelFromPath } from '@renderer/utils/taskGrouping'; import { getTeamTaskWorkflowColumn, isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; import { format, isThisYear, isToday, isYesterday } from 'date-fns'; import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import type { GlobalTask, TeamTaskStatus } from '@shared/types'; import type { LucideIcon } from 'lucide-react'; const statusConfig: Record = { pending: { icon: Circle, color: 'text-amber-400', key: 'pending' }, in_progress: { icon: Loader2, color: 'text-blue-400', key: 'in_progress' }, completed: { icon: CheckCircle2, color: 'text-emerald-400', key: 'completed' }, deleted: { icon: Circle, color: 'text-zinc-500', key: 'deleted' }, }; function formatTaskDate(dateStr: string | undefined, yesterdayLabel: string): string | null { if (!dateStr) return null; const d = new Date(dateStr); if (isNaN(d.getTime())) return null; if (isToday(d)) return format(d, 'HH:mm'); if (isYesterday(d)) return yesterdayLabel; if (isThisYear(d)) return format(d, 'MMM d'); return format(d, 'MMM d, yyyy'); } function formatUpdatedLabel( task: GlobalTask, updatedPrefix: string, updatedYesterdayLabel: string ): string | null { const updatedStr = task.updatedAt; if (!updatedStr) return null; const updated = new Date(updatedStr); if (isNaN(updated.getTime())) return null; // Don't show "updated" if there's no createdAt to compare, or times are within 60s const createdStr = task.createdAt; if (createdStr) { const created = new Date(createdStr); if (!isNaN(created.getTime()) && Math.abs(updated.getTime() - created.getTime()) < 60_000) { return null; } } if (isToday(updated)) return `${updatedPrefix} ${format(updated, 'HH:mm')}`; if (isYesterday(updated)) return updatedYesterdayLabel; if (isThisYear(updated)) return `${updatedPrefix} ${format(updated, 'MMM d')}`; return `${updatedPrefix} ${format(updated, 'MMM d, yyyy')}`; } interface SidebarTaskItemProps { task: GlobalTask; hideTeamName?: boolean; hideProjectName?: boolean; showTeamName?: boolean; /** Pauses the in-progress spinner when the parent team is offline. */ teamOffline?: boolean; /** The composite key "teamName:taskId" of the task being renamed, or null */ renamingKey?: string | null; /** Called when rename is completed with Enter or blur */ onRenameComplete?: (teamName: string, taskId: string, newSubject: string) => void; /** Called when rename is cancelled with Escape */ onRenameCancel?: () => void; /** Returns a custom display subject if the task was renamed locally */ getDisplaySubject?: (task: GlobalTask) => string | undefined; } export const SidebarTaskItem = memo(function SidebarTaskItem({ task, hideTeamName, hideProjectName, showTeamName, teamOffline = false, renamingKey, onRenameComplete, onRenameCancel, getDisplaySubject, }: SidebarTaskItemProps): React.JSX.Element { const { t } = useAppTranslation('team'); const { t: tCommon } = useAppTranslation('common'); const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); const { isLight } = useTheme(); const isRenaming = renamingKey === `${task.teamName}:${task.id}`; const displaySubject = getDisplaySubject?.(task) ?? task.subject; const [editValue, setEditValue] = useState(displaySubject); const inputRef = useRef(null); // Focus input when rename starts useEffect(() => { if (!isRenaming) return; const raf = requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); }); return () => cancelAnimationFrame(raf); }, [isRenaming]); // Reset edit value when renaming starts useEffect(() => { if (isRenaming) { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change setEditValue(displaySubject); } }, [isRenaming, displaySubject]); const reviewColumn = getTeamTaskWorkflowColumn(task); const cfg = reviewColumn === 'approved' ? ({ icon: ShieldCheck, color: 'text-teal-400', key: 'approved' } as const) : reviewColumn === 'review' ? ({ icon: Eye, color: 'text-orange-400', key: 'review' } as const) : (statusConfig[task.status] ?? statusConfig.pending); const StatusIcon = cfg.icon; const shouldAnimateStatusIcon = cfg.key === 'in_progress' && !teamOffline; const statusIconClassName = cn( 'size-3 shrink-0', cfg.color, shouldAnimateStatusIcon && 'animate-spin' ); const updatedLabel = formatUpdatedLabel( task, tCommon('tasks.date.updatedPrefix'), tCommon('tasks.date.updatedYesterday') ); const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt, tCommon('tasks.date.yesterday')); const ownerColorSet = useMemo(() => { if (!teamMembers || !task.owner) return null; const colorMap = buildMemberColorMap(teamMembers); const colorName = colorMap.get(task.owner); return colorName ? getTeamColorSet(colorName) : null; }, [teamMembers, task.owner]); const ownerTextColor = useMemo(() => { if (!ownerColorSet) return undefined; return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; }, [ownerColorSet, isLight]); const projectLabel = useMemo(() => { if (hideProjectName) return null; if (!task.projectPath?.trim()) return null; return projectLabelFromPath(task.projectPath); }, [hideProjectName, task.projectPath]); const projectColorSet = useMemo( () => (projectLabel ? projectColor(projectLabel, isLight) : null), [projectLabel, isLight] ); const teamColor = useMemo( () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), [showTeamName, task.teamDisplayName, isLight] ); const showTeamRow = showTeamName && !hideTeamName; const unreadBackgroundClass = unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; return ( ); });