schedule-card.tsx
1 'use client' 2 3 import type { Schedule } from '@/types' 4 import { useAppStore } from '@/stores/use-app-store' 5 import { api } from '@/lib/app/api-client' 6 import { cronToHuman } from '@/lib/schedules/cron-human' 7 import { AgentAvatar } from '@/components/agents/agent-avatar' 8 import { InfoChip } from '@/components/ui/info-chip' 9 import { useNow } from '@/hooks/use-now' 10 import { isUserCreatedSchedule } from '@/lib/schedules/schedule-origin' 11 12 const STATUS_COLORS: Record<string, string> = { 13 active: 'text-emerald-400 bg-emerald-400/[0.08]', 14 paused: 'text-amber-400 bg-amber-400/[0.08]', 15 completed: 'text-text-3 bg-white/[0.03]', 16 failed: 'text-red-400 bg-red-400/[0.08]', 17 } 18 19 function formatNext(ts: number | undefined, now: number | null): string { 20 if (!ts) return 'Not scheduled' 21 if (!now) return 'Scheduled' 22 const d = new Date(ts) 23 const diff = ts - now 24 if (diff < 0) return 'Overdue' 25 if (diff < 60000) return 'In < 1m' 26 if (diff < 3600000) return `In ${Math.floor(diff / 60000)}m` 27 if (diff < 86400000) return `In ${Math.floor(diff / 3600000)}h` 28 return d.toLocaleDateString() 29 } 30 31 interface Props { 32 schedule: Schedule 33 inSidebar?: boolean 34 index?: number 35 } 36 37 export function ScheduleCard({ schedule, inSidebar, index = 0 }: Props) { 38 const now = useNow({ enabled: false }) 39 const setEditingScheduleId = useAppStore((s) => s.setEditingScheduleId) 40 const setScheduleSheetOpen = useAppStore((s) => s.setScheduleSheetOpen) 41 const loadSchedules = useAppStore((s) => s.loadSchedules) 42 const agents = useAppStore((s) => s.agents) 43 44 const handleClick = () => { 45 setEditingScheduleId(schedule.id) 46 setScheduleSheetOpen(true) 47 } 48 49 const handleToggle = async (e: React.MouseEvent) => { 50 e.stopPropagation() 51 const newStatus = schedule.status === 'active' ? 'paused' : 'active' 52 await api('PUT', `/schedules/${schedule.id}`, { status: newStatus }) 53 loadSchedules() 54 } 55 56 const handleDelete = async (e: React.MouseEvent) => { 57 e.stopPropagation() 58 await api('DELETE', `/schedules/${schedule.id}`) 59 loadSchedules() 60 } 61 62 const agent = agents[schedule.agentId] 63 const creatorAgent = schedule.createdByAgentId ? agents[schedule.createdByAgentId] : null 64 const statusClass = STATUS_COLORS[schedule.status] || STATUS_COLORS.paused 65 const canToggle = schedule.status === 'active' || schedule.status === 'paused' 66 67 return ( 68 <div 69 onClick={handleClick} 70 className="relative py-3.5 px-4 cursor-pointer rounded-[14px] 71 transition-all duration-200 active:scale-[0.98] 72 bg-transparent border border-transparent hover:bg-white/[0.02] hover:border-white/[0.03] hover:scale-[1.01]" 73 style={{ 74 animation: 'spring-in 0.5s var(--ease-spring) both', 75 animationDelay: `${Math.min(index * 0.05, 0.4)}s` 76 }} 77 > 78 <div className="flex items-center gap-2.5"> 79 <span className="font-display text-[14px] font-600 truncate flex-1 tracking-[-0.01em]">{schedule.name}</span> 80 <div className="flex items-center gap-2 shrink-0"> 81 {!inSidebar && canToggle && ( 82 <div 83 onClick={handleToggle} 84 className={`w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0 85 ${schedule.status === 'active' ? 'bg-accent-bright' : 'bg-white/[0.08]'}`} 86 > 87 <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all 88 ${schedule.status === 'active' ? 'left-[18px]' : 'left-0.5'}`} 89 style={schedule.status === 'active' ? { animation: 'spring-in 0.3s var(--ease-spring)' } : undefined} 90 /> 91 </div> 92 )} 93 <span className={`text-[10px] font-600 uppercase tracking-wider px-2 py-0.5 rounded-[6px] ${statusClass}`}> 94 {schedule.status} 95 </span> 96 {!inSidebar && ( 97 <button 98 onClick={handleDelete} 99 className="text-text-3/40 hover:text-red-400 transition-colors p-0.5" 100 title="Delete" 101 > 102 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 103 <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> 104 </svg> 105 </button> 106 )} 107 </div> 108 </div> 109 <div className="text-[12px] text-text-3/70 mt-1.5 truncate"> 110 Runs on {agent?.name || 'Unknown agent'} · {schedule.scheduleType} 111 {!inSidebar && schedule.scheduleType === 'cron' && schedule.cron && ( 112 <span className="text-text-3/50 ml-1" title={schedule.cron}>({cronToHuman(schedule.cron)})</span> 113 )} 114 {!inSidebar && schedule.scheduleType === 'interval' && schedule.intervalMs && ( 115 <span className="text-text-3/50 ml-1"> 116 (every {schedule.intervalMs >= 3600000 117 ? `${Math.round(schedule.intervalMs / 3600000)}h` 118 : `${Math.round(schedule.intervalMs / 60000)}m`}) 119 </span> 120 )} 121 </div> 122 <div className="flex flex-wrap items-center gap-1.5 mt-2"> 123 {creatorAgent ? ( 124 <InfoChip tone="neutral" className="max-w-full"> 125 <AgentAvatar 126 seed={creatorAgent.avatarSeed} 127 avatarUrl={creatorAgent.avatarUrl} 128 name={creatorAgent.name} 129 size={14} 130 /> 131 <span className="truncate">Created by {creatorAgent.name}</span> 132 </InfoChip> 133 ) : ( 134 <InfoChip tone="muted" className="max-w-full"> 135 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 136 <circle cx="12" cy="8" r="4" /> 137 <path d="M4 20c1.5-3.5 4.6-5 8-5s6.5 1.5 8 5" /> 138 </svg> 139 <span className="truncate">{isUserCreatedSchedule(schedule) ? 'Created manually' : 'Creator unknown'}</span> 140 </InfoChip> 141 )} 142 </div> 143 <div className="text-[11px] text-text-3/60 mt-1"> 144 Next: {formatNext(schedule.nextRunAt, now)} 145 </div> 146 </div> 147 ) 148 }