import { useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap, Globe, Folder, Layers, Loader2 } from "lucide-react"; import type { Routine, RoutineExecutionResult, RoutineTriggerType, RoutineCatchUpPolicy, RoutineExecutionPolicy } from "@fusion/core"; import { useConfirm } from "../hooks/useConfirm"; /** * Format a duration in milliseconds to a human-readable string. */ function formatDurationMs(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (minutes < 60) return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; } /** * Format an ISO timestamp to a relative time string. * * FNXC:RelativeTime 2026-06-17-20:48: * FN-6618 deliberately keeps RoutineCard separate from getRelativeTimeBucket because scheduled routines expose future-time copy (`in a moment`, `in Xm`, `in Xh`, `in Xd`) that the shared helper represents as null. */ function relativeTime(iso: string): string { const now = Date.now(); const then = new Date(iso).getTime(); const diffMs = now - then; // Future if (diffMs < 0) { const absDiff = Math.abs(diffMs); if (absDiff < 60_000) return "in a moment"; if (absDiff < 3_600_000) return `in ${Math.floor(absDiff / 60_000)}m`; if (absDiff < 86_400_000) return `in ${Math.floor(absDiff / 3_600_000)}h`; return `in ${Math.floor(absDiff / 86_400_000)}d`; } // Past if (diffMs < 60_000) return "just now"; if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`; if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`; return `${Math.floor(diffMs / 86_400_000)}d ago`; } /* FNXC:Automations 2026-06-22-12:00: Trigger-type badge colors must use the design system's THEME COLOR TOKENS so the Automations screen follows the active theme (including light theme) instead of fixed hex literals. The previous values referenced undefined tokens (--color-blue/-purple/-green/-gray) with hardcoded hex fallbacks that never resolved to a real token and never adapted to the theme. Mapped to the closest defined semantic tokens from styles.css: cron→--todo (blue status), webhook→--accent (brand purple), api→--color-success (green), manual→--text-muted (neutral). The badge applies this to both border and text via inline style on .routine-trigger-badge. */ const TRIGGER_TYPE_COLORS: Record = { cron: "var(--todo)", webhook: "var(--accent)", api: "var(--color-success)", manual: "var(--text-muted)", }; const TRIGGER_TYPE_LABELS: Record = { cron: "Cron", webhook: "Webhook", api: "API", manual: "Manual", }; const TRIGGER_TYPE_ICONS: Record> = { cron: Calendar, webhook: Webhook, api: Code, manual: Zap, }; const EXECUTION_POLICY_LABELS: Record = { parallel: "Concurrent", queue: "Queued", reject: "Exclusive", }; const CATCH_UP_POLICY_LABELS: Record = { run: "Catch up", skip: "Skip missed", run_one: "Catch up (latest)", }; interface RoutineCardProps { routine: Routine; onEdit: (routine: Routine) => void; onDelete: (routine: Routine) => void; onRun: (routine: Routine) => void; onToggle: (routine: Routine) => void; /** Whether a manual run is currently in progress. */ running?: boolean; /** Latest manual-run output shown inline until refreshed routine data catches up. */ lastRunOutput?: { output: string; error?: string; success: boolean } | null; /** Incremental live run transcript while a manual run is in progress. */ liveRunOutput?: { output: string; status: "idle" | "running" | "complete" | "error" } | null; } function RunResultBadge({ result }: { result: RoutineExecutionResult }) { const { t } = useTranslation("app"); const duration = result.completedAt && result.startedAt ? new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime() : 0; return ( {result.success ? ( ) : ( )} {result.success ? t("routine.resultSuccess", "Success") : t("routine.resultFailed", "Failed")} {duration > 0 && ( {formatDurationMs(duration)} )} ); } function RunHistoryItem({ result, index }: { result: RoutineExecutionResult; index: number }) { const [expanded, setExpanded] = useState(false); const duration = result.completedAt && result.startedAt ? new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime() : 0; return (
{expanded && (
{result.output && (
{result.output}
)} {result.error && (
{result.error}
)}
)}
); } export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, running, lastRunOutput, liveRunOutput }: RoutineCardProps) { const { t } = useTranslation("app"); const [showHistory, setShowHistory] = useState(false); const { confirm } = useConfirm(); const handleDelete = useCallback(async () => { const shouldDelete = await confirm({ title: t("routine.deleteTitle", "Delete Routine"), message: t("routine.deleteMessage", "Delete routine \"{{name}}\"? This cannot be undone.", { name: routine.name }), danger: true, }); if (shouldDelete) { onDelete(routine); } }, [routine, onDelete, confirm, t]); const triggerColor = TRIGGER_TYPE_COLORS[routine.trigger.type]; const TriggerIcon = TRIGGER_TYPE_ICONS[routine.trigger.type]; // Get cron expression if available (from trigger or direct field) const cronExpression = routine.trigger.type === "cron" ? (("cronExpression" in routine.trigger ? routine.trigger.cronExpression : undefined) as string | undefined) || routine.cronExpression || "" : routine.cronExpression || ""; return (
{routine.name} {TRIGGER_TYPE_LABELS[routine.trigger.type]} {routine.scope && ( {routine.scope === "global" ? : } {routine.scope} )}
{routine.description && (

{routine.description}

)}
{routine.steps && routine.steps.length > 0 ? (
{t("routine.stepCount", "{{count}} step", { count: routine.steps.length, defaultValue_one: "{{count}} step", defaultValue_other: "{{count}} steps" })}
) : routine.command ? (
{routine.command}
) : null} {/* Cron expression for cron triggers */} {routine.trigger.type === "cron" && cronExpression && (
{cronExpression}
)} {/* Policy badges */}
{EXECUTION_POLICY_LABELS[routine.executionPolicy]}
{CATCH_UP_POLICY_LABELS[routine.catchUpPolicy]}
{/* Timing info */} {routine.nextRunAt && routine.enabled && (
{t("schedule.nextLabel", "Next:")} {relativeTime(routine.nextRunAt)}
)} {routine.lastRunAt && (
{t("schedule.lastLabel", "Last:")} {relativeTime(routine.lastRunAt)}
)} {routine.lastRunResult && ( )}
{t("schedule.runsLabel", "Runs:")} {routine.runCount}
{liveRunOutput && (
{liveRunOutput.status === "running" ? t("schedule.liveOutputRunning", "Live output — running") : t("schedule.liveOutput", "Live output")}
{liveRunOutput.output && (
{liveRunOutput.output}
)}
)} {lastRunOutput && (
{lastRunOutput.output && (
{lastRunOutput.output}
)} {lastRunOutput.error && (
{lastRunOutput.error}
)}
)} {routine.runHistory.length > 0 && (
{showHistory && (
{routine.runHistory.slice(0, 10).map((result, i) => ( ))} {routine.runHistory.length > 10 && (
{t("routine.andMore", "…and {{count}} more", { count: routine.runHistory.length - 10 })}
)}
)}
)}
); }