import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; 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', label: 'pending' }, in_progress: { icon: Loader2, color: 'text-blue-400', label: 'in progress' }, completed: { icon: CheckCircle2, color: 'text-emerald-400', label: 'completed' }, deleted: { icon: Circle, color: 'text-zinc-500', label: 'deleted' }, }; function formatTaskDate(dateStr: string | undefined): 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 'Yesterday'; if (isThisYear(d)) return format(d, 'MMM d'); return format(d, 'MMM d, yyyy'); } function formatUpdatedLabel(task: GlobalTask): 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 `upd ${format(updated, 'HH:mm')}`; if (isYesterday(updated)) return 'upd yesterday'; if (isThisYear(updated)) return `upd ${format(updated, 'MMM d')}`; return `upd ${format(updated, 'MMM d, yyyy')}`; } interface SidebarTaskItemProps { task: GlobalTask; hideTeamName?: boolean; showTeamName?: 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, showTeamName, renamingKey, onRenameComplete, onRenameCancel, getDisplaySubject, }: SidebarTaskItemProps): React.JSX.Element { 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', label: 'approved' } as const) : reviewColumn === 'review' ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) : (statusConfig[task.status] ?? statusConfig.pending); const StatusIcon = cfg.icon; const updatedLabel = formatUpdatedLabel(task); const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); 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 (!task.projectPath?.trim()) return null; return projectLabelFromPath(task.projectPath); }, [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 ( ); });