import { useState, useMemo, useEffect, useRef } from 'react'; import { ChevronLeft, ChevronRight, Clock, Layers, Printer as PrinterIcon } from 'lucide-react'; import { formatDuration, parseUTCDate } from '../utils/date'; import type { PrintQueueItem, Printer } from '../api/client'; import { api } from '../api/client'; import { Button } from './Button'; /** Gantt-style 24h-rolling timeline. One horizontal swimlane per printer * (plus one per active target_model and one for unassigned items). Each * pending or printing job is rendered as a colored bar positioned by its * predicted start time, width = predicted duration. A vertical NOW line * marks current time. Hover a bar for details, click to edit/stop. */ const HOUR_MS = 60 * 60 * 1000; const RANGE_HOURS = 24; const RANGE_MS = RANGE_HOURS * HOUR_MS; // Minimum bar width — short prints (a few minutes) would otherwise render as // 1-2px slivers and be unclickable. 32px keeps them at thumb size. const MIN_BAR_PX = 32; // Lane height for the bar row + label. const LANE_BAR_HEIGHT_PX = 40; interface ScheduleEvent { item: PrintQueueItem; estimatedStart: Date; estimatedEnd: Date; progress?: number; type: 'printing' | 'queued'; } interface QueueTimelineViewProps { queueItems: PrintQueueItem[]; printers: Printer[]; printerStatuses: Record; onItemClick: (item: PrintQueueItem) => void; t: (key: string, options?: Record) => string; } interface LaneDescriptor { key: string; label: string; /** null for model-based / unassigned lanes. */ printerId: number | null; /** Set for model-based lanes (`Any X1C`). */ targetModel: string | null; } function formatHour(date: Date): string { return date.toLocaleTimeString(undefined, { hour: 'numeric' }); } function formatTooltipTime(date: Date): string { return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } export function QueueTimelineView({ queueItems, printers, printerStatuses, onItemClick, t, }: QueueTimelineViewProps) { // Tick "now" every minute so the NOW line and ETA labels stay live. const [now, setNow] = useState(() => new Date()); useEffect(() => { const interval = setInterval(() => setNow(new Date()), 60_000); return () => clearInterval(interval); }, []); // User can shift the window forward/back in 12h steps. Default = current // time, so the timeline reads "next 24h from now." const [windowOffsetMs, setWindowOffsetMs] = useState(0); // Round the window start down to the previous full hour so the axis ticks // land on whole hours. const rangeStartMs = useMemo(() => { const target = now.getTime() + windowOffsetMs; return Math.floor(target / HOUR_MS) * HOUR_MS; }, [now, windowOffsetMs]); const rangeEndMs = rangeStartMs + RANGE_MS; const nowMs = now.getTime(); // Build schedule events. Only committed schedules are rendered: // • currently printing → always // • pending with explicit scheduled_time → at that time // • pending ASAP that chain behind a print actually running on the same // lane → forecast // Staged (manual_start) and waiting (waiting_reason) items are not on the // timeline because they won't auto-dispatch — they'd be misleading bars. // Idle-printer ASAP queues also stay off until something starts on them. const events = useMemo(() => { const result: ScheduleEvent[] = []; const pendingByLaneKey = new Map(); // Lanes that have an active print right now — only these qualify for // ASAP chain forecasting. const lanesWithActive = new Set(); // Chain-end timestamp per lane (where the next pending item's bar starts). const chainEndByLane = new Map(); const laneKeyOf = (item: PrintQueueItem): string => { if (item.printer_id != null) return `printer:${item.printer_id}`; if (item.target_model) return `model:${item.target_model}`; return 'unassigned'; }; for (const item of queueItems) { if (item.status === 'printing') { const status = item.printer_id != null ? printerStatuses[item.printer_id] : undefined; const start = parseUTCDate(item.started_at) || new Date(); let endTime: Date; if (status?.remaining_time != null && status.remaining_time > 0) { endTime = new Date(nowMs + status.remaining_time * 60 * 1000); } else if (item.print_time_seconds) { const progress = status?.progress || 0; const remainingFraction = Math.max(0, 1 - progress / 100); endTime = new Date(nowMs + item.print_time_seconds * remainingFraction * 1000); } else { endTime = new Date(nowMs + HOUR_MS); } result.push({ item, estimatedStart: start, estimatedEnd: endTime, progress: status?.progress ?? undefined, type: 'printing', }); const lk = laneKeyOf(item); lanesWithActive.add(lk); chainEndByLane.set(lk, Math.max(chainEndByLane.get(lk) ?? nowMs, endTime.getTime())); } else if (item.status === 'pending') { // Skip un-committed pending shapes — staged items and waiting items // won't auto-dispatch, so a bar would lie. if (item.manual_start) continue; if (item.waiting_reason) continue; const lk = laneKeyOf(item); if (!pendingByLaneKey.has(lk)) pendingByLaneKey.set(lk, []); pendingByLaneKey.get(lk)!.push(item); } } const sixMonthsFromNow = Date.now() + 180 * 24 * HOUR_MS; for (const [lk, items] of pendingByLaneKey) { items.sort((a, b) => a.position - b.position); const hasActive = lanesWithActive.has(lk); // A lane is timelineable when EITHER it has an active print (chain // forecast off its end) OR its first pending item is scheduled (a // committed anchor exists). Otherwise every chained ASAP item is just // a guess — drop the whole lane to keep the view honest. const firstScheduled = items[0] ? parseUTCDate(items[0].scheduled_time) : null; const firstScheduledOk = firstScheduled && firstScheduled.getTime() <= sixMonthsFromNow; if (!hasActive && !firstScheduledOk) continue; let chainEnd = chainEndByLane.get(lk) ?? nowMs; for (const item of items) { const scheduled = parseUTCDate(item.scheduled_time); if (scheduled && scheduled.getTime() <= sixMonthsFromNow) { chainEnd = Math.max(chainEnd, scheduled.getTime()); } const duration = (item.print_time_seconds || 3600) * 1000; result.push({ item, estimatedStart: new Date(chainEnd), estimatedEnd: new Date(chainEnd + duration), type: 'queued', }); chainEnd += duration; } } return result; }, [queueItems, printerStatuses, nowMs]); // Lanes: every printer + every distinct target_model with queue activity // + an "unassigned" lane if needed. Printers that have NO events queued // still get a lane so users see idle capacity. const lanes = useMemo(() => { const list: LaneDescriptor[] = []; for (const p of printers) { list.push({ key: `printer:${p.id}`, label: p.name, printerId: p.id, targetModel: null, }); } const modelLanesAdded = new Set(); let hasUnassigned = false; for (const ev of events) { if (ev.item.printer_id != null) continue; if (ev.item.target_model) { const k = `model:${ev.item.target_model}`; if (!modelLanesAdded.has(k)) { modelLanesAdded.add(k); list.push({ key: k, label: `${t('queue.filter.any')} ${ev.item.target_model}`, printerId: null, targetModel: ev.item.target_model, }); } } else { hasUnassigned = true; } } if (hasUnassigned) { list.push({ key: 'unassigned', label: t('queue.filter.unassigned'), printerId: null, targetModel: null, }); } return list; }, [printers, events, t]); const eventsByLane = useMemo(() => { const map = new Map(); for (const ev of events) { let key: string; if (ev.item.printer_id != null) key = `printer:${ev.item.printer_id}`; else if (ev.item.target_model) key = `model:${ev.item.target_model}`; else key = 'unassigned'; if (!map.has(key)) map.set(key, []); map.get(key)!.push(ev); } return map; }, [events]); const hourTicks = useMemo(() => { const ticks: { ms: number; pct: number; label: string }[] = []; for (let h = 0; h <= RANGE_HOURS; h += 2) { const ms = rangeStartMs + h * HOUR_MS; ticks.push({ ms, pct: (h / RANGE_HOURS) * 100, label: formatHour(new Date(ms)), }); } return ticks; }, [rangeStartMs]); const nowPct = ((nowMs - rangeStartMs) / RANGE_MS) * 100; const nowInView = nowPct >= 0 && nowPct <= 100; // Aggregated "all done by" across the entire (un-windowed) event set. const allDoneBy = useMemo(() => { let latest = 0; for (const ev of events) latest = Math.max(latest, ev.estimatedEnd.getTime()); return latest > 0 ? new Date(latest) : null; }, [events]); const trackRef = useRef(null); return (
{/* Window controls */}
{new Date(rangeStartMs).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', })} {' → '} {new Date(rangeEndMs).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} {windowOffsetMs !== 0 && ( )}
{allDoneBy && ( {t('queue.timeline.allDoneBy', { time: allDoneBy.toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit', }), })} )}
{/* Empty-state notice when the fleet is idle and no queued item is committed (no scheduled_time / no active print to chain off). Without this, users see striped lanes with no bars and assume the timeline is broken — common confusion from the GHSA-r2qv era. */} {lanes.length > 0 && events.length === 0 && (
{t('queue.timeline.nothingCommitted')}
)} {lanes.length === 0 ? (

{t('queue.timeline.noData')}

) : (
{/* Hour axis */}
{t('queue.timeline.printerColumnHeader')}
{hourTicks.map((tick) => (
{tick.label}
))}
{/* Lanes */}
{lanes.map((lane) => { const laneEvents = eventsByLane.get(lane.key) ?? []; return (
{lane.label}
{/* Hour grid lines */} {hourTicks.map((tick) => (
))} {/* Idle background — diagonal stripes hint that the lane is sittable. Rendered behind bars so they overlay it. */}
{/* Job bars */} {laneEvents.map((ev) => { // Clip to the visible window. const startMs = Math.max(rangeStartMs, ev.estimatedStart.getTime()); const endMs = Math.min(rangeEndMs, ev.estimatedEnd.getTime()); if (endMs <= rangeStartMs || startMs >= rangeEndMs) return null; const leftPct = ((startMs - rangeStartMs) / RANGE_MS) * 100; const widthPct = ((endMs - startMs) / RANGE_MS) * 100; const displayName = ev.item.archive_name || ev.item.library_file_name || `#${ev.item.id}`; const thumbnailUrl = ev.item.archive_thumbnail ? api.getArchiveThumbnail(ev.item.archive_id!) : ev.item.library_file_thumbnail ? api.getLibraryFileThumbnailUrl(ev.item.library_file_id!) : null; const isPrinting = ev.type === 'printing'; const isBatched = ev.item.batch_id != null; const tooltipParts = [ displayName, `${formatTooltipTime(ev.estimatedStart)} → ${formatTooltipTime(ev.estimatedEnd)}`, ev.item.print_time_seconds ? formatDuration(ev.item.print_time_seconds) : null, isPrinting && ev.progress != null ? `${Math.round(ev.progress)}%` : null, ev.item.batch_name ? `batch: ${ev.item.batch_name}` : null, ].filter(Boolean).join(' · '); return ( ); })}
); })} {/* NOW line — drawn on top of all lanes. Use the same label-column offset (w-32 sm:w-40) as the lanes so the line aligns exactly with the time track. */} {nowInView && (
)}
)}
); }