import { memo, useCallback, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Play, Pause, Trash2, Folder, ArrowRight } from "lucide-react"; import "./ProjectCard.css"; import type { RegisteredProject, ProjectHealth } from "@fusion/core"; import type { ProjectNodeAvailability } from "../api"; import { getProjectStatusConfig, isInitializingStatus } from "../utils/projectStatusConfig"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; export interface ProjectCardProps { project: RegisteredProject; health: ProjectHealth | null; onSelect: (project: RegisteredProject) => void; onPause: (project: RegisteredProject) => void; onResume: (project: RegisteredProject) => void; onRemove: (project: RegisteredProject) => void; availabilityMappings?: Array; isLoading?: boolean; } function formatRelativeTime(timestamp: string | undefined, t: TFunction<"app">): string { if (!timestamp) return t("projectCard.never", "Never"); /* * FNXC:RelativeTime 2026-06-17-20:48: * FN-6618 reuses shared relative-time buckets while preserving ProjectCard's Never guard, projectCard.* i18n keys, future-as-Just-now behavior, and no-options date fallback. */ const bucket = getRelativeTimeBucket(timestamp); if (!bucket) { const timestampMs = Date.parse(timestamp); if (Number.isFinite(timestampMs) && Date.now() - timestampMs < 0) return t("projectCard.justNow", "Just now"); return new Date(timestamp).toLocaleDateString(); } switch (bucket.bucket) { case "just-now": return t("projectCard.justNow", "Just now"); case "minutes": return t("projectCard.minutesAgo", "{{count}}m ago", { count: bucket.count }); case "hours": return t("projectCard.hoursAgo", "{{count}}h ago", { count: bucket.count }); case "days": return t("projectCard.daysAgo", "{{count}}d ago", { count: bucket.count }); case "weeks": case "older": return bucket.date.toLocaleDateString(); } } function truncatePath(path: string, maxLength: number = 40): string { if (path.length <= maxLength) return path; const start = path.slice(0, Math.floor(maxLength / 2) - 2); const end = path.slice(-Math.floor(maxLength / 2) + 2); return `${start}...${end}`; } function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardProps): boolean { if (previous.project.id !== next.project.id) return false; if (previous.project.status !== next.project.status) return false; if (previous.project.name !== next.project.name) return false; if (previous.project.path !== next.project.path) return false; if (previous.project.lastActivityAt !== next.project.lastActivityAt) return false; if (previous.isLoading !== next.isLoading) return false; // Compare health const prevHealth = previous.health; const nextHealth = next.health; if (!prevHealth && !nextHealth) return true; if (!prevHealth || !nextHealth) return false; if ( prevHealth.activeTaskCount !== nextHealth.activeTaskCount || prevHealth.inFlightAgentCount !== nextHealth.inFlightAgentCount || prevHealth.totalTasksCompleted !== nextHealth.totalTasksCompleted || prevHealth.totalTasksFailed !== nextHealth.totalTasksFailed || prevHealth.status !== nextHealth.status ) { return false; } const prevMappings = previous.availabilityMappings ?? []; const nextMappings = next.availabilityMappings ?? []; if (prevMappings.length !== nextMappings.length) return false; return prevMappings.every((mapping, index) => { const nextMapping = nextMappings[index]; return Boolean(nextMapping) && mapping.nodeId === nextMapping.nodeId && mapping.path === nextMapping.path && mapping.displayName === nextMapping.displayName && mapping.available === nextMapping.available; }); } function ProjectCardInner({ project, health, onSelect, onPause, onResume, onRemove, availabilityMappings = [], isLoading = false, }: ProjectCardProps) { const { t } = useTranslation("app"); const [removeArmed, setRemoveArmed] = useState(false); const statusConfig = getProjectStatusConfig(project.status); const StatusIcon = statusConfig.icon; const handleSelect = useCallback(() => { onSelect(project); }, [onSelect, project]); const handlePause = useCallback((e: React.MouseEvent) => { e.stopPropagation(); onPause(project); }, [onPause, project]); const handleResume = useCallback((e: React.MouseEvent) => { e.stopPropagation(); onResume(project); }, [onResume, project]); const handleRemove = useCallback((e: React.MouseEvent) => { e.stopPropagation(); if (!removeArmed) { setRemoveArmed(true); return; } onRemove(project); setRemoveArmed(false); }, [removeArmed, onRemove, project]); const isPaused = project.status === "paused"; const isErrored = project.status === "errored"; const isInitializing = isInitializingStatus(project.status); return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleSelect(); } }} >

{project.name}

{availabilityMappings.length > 0 && (
{availabilityMappings.slice(0, 3).map((mapping) => (
{mapping.displayName} → {truncatePath(mapping.path, 28)}
))} {availabilityMappings.length > 3 && ( {t("projectCard.moreItems", "+{{count}} more", { count: availabilityMappings.length - 3 })} )}
)} {truncatePath(project.path)}
{statusConfig.label}
{health && ( <>
{health.activeTaskCount} {t("projectCard.activeTasks", "Active Tasks")}
{health.inFlightAgentCount} {t("projectCard.agents", "Agents")}
{health.totalTasksCompleted} {t("projectCard.completed", "Completed")}
)} {!health && (
{t("projectCard.noHealthData", "No health data available")}
)}
{t("projectCard.lastActivity", "Last activity:")} {formatRelativeTime(project.lastActivityAt || health?.lastActivityAt, t)}
{isPaused ? ( ) : ( )}
); } export const ProjectCard = memo(ProjectCardInner, areProjectCardPropsEqual);