import { useState, useMemo, useEffect } from 'react'; import { ChevronLeft, ChevronRight, Clock, Layers, Printer as PrinterIcon } from 'lucide-react'; import { formatDuration, parseUTCDate } from '../utils/date'; import type { PrintQueueItem } from '../api/client'; import { api } from '../api/client'; import { Button } from './Button'; type FilterMode = 'all' | 'printing' | 'queued'; interface ScheduleEvent { item: PrintQueueItem; estimatedEnd: Date; estimatedStart: Date; progress?: number; type: 'printing' | 'queued'; } interface QueueTimelineViewProps { queueItems: PrintQueueItem[]; printerStatuses: Record; onItemClick: (item: PrintQueueItem) => void; t: (key: string, options?: Record) => string; } function getStartOfDay(date: Date): Date { const d = new Date(date); d.setHours(0, 0, 0, 0); return d; } function formatDateLabel(date: Date): string { return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); } function formatTimeOnly(date: Date): string { return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } function formatTimeLeft(ms: number, t: (key: string, opts?: Record) => string): string { if (ms <= 0) return t('queue.timeline.time.anyMoment'); const totalMin = Math.round(ms / 60000); if (totalMin < 60) return t('queue.timeline.time.minutesLeft', { minutes: totalMin }); const hours = Math.floor(totalMin / 60); const mins = totalMin % 60; if (mins === 0) return t('queue.timeline.time.hoursLeft', { hours }); return t('queue.timeline.time.hoursMinutesLeft', { hours, minutes: mins }); } function getHourLabel(hour: number): string { const date = new Date(); date.setHours(hour, 0, 0, 0); return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } function ScheduleCard({ event, now, onItemClick, t, }: { event: ScheduleEvent; now: Date; onItemClick: (item: PrintQueueItem) => void; t: (key: string, opts?: Record) => string; }) { const item = event.item; const displayName = item.archive_name || item.library_file_name || t('common.unknown'); const printerName = item.printer_name || (item.target_model ? `${t('queue.filter.any')} ${item.target_model}` : t('queue.timeline.unassigned')); const isPrinting = event.type === 'printing'; const timeLeft = event.estimatedEnd.getTime() - now.getTime(); const thumbnailUrl = item.archive_thumbnail ? api.getArchiveThumbnail(item.archive_id!) : item.library_file_thumbnail ? api.getLibraryFileThumbnailUrl(item.library_file_id!) : null; return (
onItemClick(item)} > {/* Left accent */}
{/* Thumbnail */}
{thumbnailUrl ? ( ) : (
)}
{/* Info */}

{displayName}

{printerName} {item.print_time_seconds && ( {formatDuration(item.print_time_seconds)} )}
{/* Progress bar for active prints */} {isPrinting && event.progress != null && (
{Math.round(event.progress)}%
)}
{/* Time info */}

{formatTimeOnly(event.estimatedEnd)}

{formatTimeLeft(timeLeft, t)}

); } export function QueueTimelineView({ queueItems, printerStatuses, onItemClick, t, }: QueueTimelineViewProps) { const [viewDate, setViewDate] = useState(() => getStartOfDay(new Date())); const [now, setNow] = useState(() => new Date()); const [filter, setFilter] = useState('all'); // Update "now" every 60 seconds useEffect(() => { const interval = setInterval(() => setNow(new Date()), 60000); return () => clearInterval(interval); }, []); const nowMs = now.getTime(); const isToday = getStartOfDay(new Date()).getTime() === getStartOfDay(viewDate).getTime(); // Build schedule events with ETA chaining const events = useMemo(() => { const result: ScheduleEvent[] = []; // Group pending items by printer for chaining const pendingByPrinter = new Map(); 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 + 3600000); } result.push({ item, estimatedStart: start, estimatedEnd: endTime, progress: status?.progress ?? undefined, type: 'printing', }); } else if (item.status === 'pending') { const pid = item.printer_id; if (!pendingByPrinter.has(pid)) pendingByPrinter.set(pid, []); pendingByPrinter.get(pid)!.push(item); } } // Chain pending items per printer for (const [printerId, items] of pendingByPrinter) { items.sort((a, b) => a.position - b.position); // Find when the current active print on this printer ends let chainEnd = nowMs; for (const ev of result) { if (ev.item.printer_id === printerId && ev.type === 'printing') { chainEnd = Math.max(chainEnd, ev.estimatedEnd.getTime()); } } for (const item of items) { // Respect scheduled_time const scheduledTime = parseUTCDate(item.scheduled_time); if (scheduledTime) { const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000); if (scheduledTime.getTime() <= sixMonthsFromNow) { chainEnd = Math.max(chainEnd, scheduledTime.getTime()); } } const duration = (item.print_time_seconds || 3600) * 1000; const startTime = new Date(chainEnd); const endTime = new Date(chainEnd + duration); result.push({ item, estimatedStart: startTime, estimatedEnd: endTime, type: 'queued', }); chainEnd = endTime.getTime(); } } // Sort by estimated end time result.sort((a, b) => a.estimatedEnd.getTime() - b.estimatedEnd.getTime()); return result; }, [queueItems, printerStatuses, nowMs]); // Filter events for the selected day const viewDayStart = getStartOfDay(viewDate).getTime(); const viewDayEnd = viewDayStart + 24 * 60 * 60 * 1000 - 1; const filteredEvents = useMemo(() => { return events.filter(ev => { // Event finishes within the viewed day const endMs = ev.estimatedEnd.getTime(); if (endMs < viewDayStart || endMs > viewDayEnd) return false; // Filter by type if (filter === 'printing') return ev.type === 'printing'; if (filter === 'queued') return ev.type === 'queued'; return true; }); }, [events, viewDayStart, viewDayEnd, filter]); // Group events by hour for time markers const groupedByHour = useMemo(() => { const groups: Map = new Map(); for (const ev of filteredEvents) { const hour = ev.estimatedEnd.getHours(); if (!groups.has(hour)) groups.set(hour, []); groups.get(hour)!.push(ev); } // Sort by hour return Array.from(groups.entries()).sort(([a], [b]) => a - b); }, [filteredEvents]); // Counts for filter tabs const printingCount = events.filter(ev => ev.type === 'printing' && ev.estimatedEnd.getTime() >= viewDayStart && ev.estimatedEnd.getTime() <= viewDayEnd).length; const queuedCount = events.filter(ev => ev.type === 'queued' && ev.estimatedEnd.getTime() >= viewDayStart && ev.estimatedEnd.getTime() <= viewDayEnd).length; // Overall completion estimate 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 goToday = () => setViewDate(getStartOfDay(new Date())); const goPrev = () => { const d = new Date(viewDate); d.setDate(d.getDate() - 1); setViewDate(d); }; const goNext = () => { const d = new Date(viewDate); d.setDate(d.getDate() + 1); setViewDate(d); }; const filterTabs: { key: FilterMode; label: string; count: number }[] = [ { key: 'all', label: t('queue.timeline.filterAll'), count: printingCount + queuedCount }, { key: 'printing', label: t('queue.timeline.filterPrinting'), count: printingCount }, { key: 'queued', label: t('queue.timeline.filterQueued'), count: queuedCount }, ]; return (
{/* Header */}
{/* Day navigation */}
{formatDateLabel(viewDate)} {!isToday && ( )}
{allDoneBy && ( {t('queue.timeline.allDoneBy', { time: allDoneBy.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }), })} )}
{/* Filter tabs */}
{filterTabs.map((tab) => ( ))}
{/* Schedule feed */} {groupedByHour.length > 0 ? (
{groupedByHour.map(([hour, hourEvents]) => (
{/* Hour marker */}
{getHourLabel(hour)}
{/* Events in this hour */}
{hourEvents.map((event) => ( ))}
))}
) : (

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

)}
); }