|
|
@@ -1,172 +1,113 @@
|
|
|
-import { useState, useMemo, useEffect } from 'react';
|
|
|
+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 } from '../api/client';
|
|
|
+import type { PrintQueueItem, Printer } from '../api/client';
|
|
|
import { api } from '../api/client';
|
|
|
import { Button } from './Button';
|
|
|
|
|
|
-type FilterMode = 'all' | 'printing' | 'queued';
|
|
|
+/** 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;
|
|
|
- estimatedEnd: Date;
|
|
|
estimatedStart: Date;
|
|
|
+ estimatedEnd: Date;
|
|
|
progress?: number;
|
|
|
type: 'printing' | 'queued';
|
|
|
}
|
|
|
|
|
|
interface QueueTimelineViewProps {
|
|
|
queueItems: PrintQueueItem[];
|
|
|
+ printers: Printer[];
|
|
|
printerStatuses: Record<number, { progress?: number; remaining_time?: number; state?: string }>;
|
|
|
onItemClick: (item: PrintQueueItem) => void;
|
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
|
}
|
|
|
|
|
|
-function getStartOfDay(date: Date): Date {
|
|
|
- const d = new Date(date);
|
|
|
- d.setHours(0, 0, 0, 0);
|
|
|
- return d;
|
|
|
+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 formatDateLabel(date: Date): string {
|
|
|
- return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
|
|
+function formatHour(date: Date): string {
|
|
|
+ return date.toLocaleTimeString(undefined, { hour: 'numeric' });
|
|
|
}
|
|
|
|
|
|
-function formatTimeOnly(date: Date): string {
|
|
|
+function formatTooltipTime(date: Date): string {
|
|
|
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
|
}
|
|
|
|
|
|
-function formatTimeLeft(ms: number, t: (key: string, opts?: Record<string, unknown>) => 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, unknown>) => 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 (
|
|
|
- <div
|
|
|
- className={`flex items-center gap-3 px-3 sm:px-4 py-3 bg-bambu-dark-secondary rounded-xl border cursor-pointer transition-all hover:border-bambu-green/40
|
|
|
- ${isPrinting ? 'border-blue-500/30' : 'border-bambu-dark-tertiary'}`}
|
|
|
- onClick={() => onItemClick(item)}
|
|
|
- >
|
|
|
- {/* Left accent */}
|
|
|
- <div className={`w-1 self-stretch rounded-full shrink-0 ${isPrinting ? 'bg-blue-500' : 'bg-bambu-green/40'}`} />
|
|
|
-
|
|
|
- {/* Thumbnail */}
|
|
|
- <div className="w-10 h-10 shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
|
|
|
- {thumbnailUrl ? (
|
|
|
- <img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
|
|
- ) : (
|
|
|
- <div className="w-full h-full flex items-center justify-center text-bambu-gray">
|
|
|
- <Layers className="w-5 h-5" />
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Info */}
|
|
|
- <div className="flex-1 min-w-0">
|
|
|
- <p className="text-sm text-white font-medium truncate">{displayName}</p>
|
|
|
- <div className="flex items-center gap-2 mt-0.5">
|
|
|
- <span className="flex items-center gap-1 text-xs text-bambu-gray">
|
|
|
- <PrinterIcon className="w-3 h-3" />
|
|
|
- <span className="truncate max-w-[120px] sm:max-w-none">{printerName}</span>
|
|
|
- </span>
|
|
|
- {item.print_time_seconds && (
|
|
|
- <span className="hidden sm:inline text-xs text-bambu-gray">
|
|
|
- {formatDuration(item.print_time_seconds)}
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Progress bar for active prints */}
|
|
|
- {isPrinting && event.progress != null && (
|
|
|
- <div className="flex items-center gap-2 mt-1.5">
|
|
|
- <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-1.5">
|
|
|
- <div
|
|
|
- className="bg-blue-500 h-1.5 rounded-full transition-all"
|
|
|
- style={{ width: `${event.progress}%` }}
|
|
|
- />
|
|
|
- </div>
|
|
|
- <span className="text-xs text-blue-400 shrink-0">{Math.round(event.progress)}%</span>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Time info */}
|
|
|
- <div className="text-right shrink-0">
|
|
|
- <p className="text-sm text-white font-medium">{formatTimeOnly(event.estimatedEnd)}</p>
|
|
|
- <p className={`text-xs mt-0.5 ${isPrinting ? 'text-blue-400' : 'text-bambu-gray'}`}>
|
|
|
- {formatTimeLeft(timeLeft, t)}
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
export function QueueTimelineView({
|
|
|
queueItems,
|
|
|
+ printers,
|
|
|
printerStatuses,
|
|
|
onItemClick,
|
|
|
t,
|
|
|
}: QueueTimelineViewProps) {
|
|
|
- const [viewDate, setViewDate] = useState(() => getStartOfDay(new Date()));
|
|
|
+ // Tick "now" every minute so the NOW line and ETA labels stay live.
|
|
|
const [now, setNow] = useState(() => new Date());
|
|
|
- const [filter, setFilter] = useState<FilterMode>('all');
|
|
|
-
|
|
|
- // Update "now" every 60 seconds
|
|
|
useEffect(() => {
|
|
|
- const interval = setInterval(() => setNow(new Date()), 60000);
|
|
|
+ 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();
|
|
|
- const isToday = getStartOfDay(new Date()).getTime() === getStartOfDay(viewDate).getTime();
|
|
|
|
|
|
- // Build schedule events with ETA chaining
|
|
|
- const events = useMemo(() => {
|
|
|
+ // 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<ScheduleEvent[]>(() => {
|
|
|
const result: ScheduleEvent[] = [];
|
|
|
-
|
|
|
- // Group pending items by printer for chaining
|
|
|
- const pendingByPrinter = new Map<number | null, PrintQueueItem[]>();
|
|
|
+ const pendingByLaneKey = new Map<string, PrintQueueItem[]>();
|
|
|
+ // Lanes that have an active print right now — only these qualify for
|
|
|
+ // ASAP chain forecasting.
|
|
|
+ const lanesWithActive = new Set<string>();
|
|
|
+ // Chain-end timestamp per lane (where the next pending item's bar starts).
|
|
|
+ const chainEndByLane = new Map<string, number>();
|
|
|
+
|
|
|
+ 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) {
|
|
|
@@ -174,9 +115,8 @@ export function QueueTimelineView({
|
|
|
const remainingFraction = Math.max(0, 1 - progress / 100);
|
|
|
endTime = new Date(nowMs + item.print_time_seconds * remainingFraction * 1000);
|
|
|
} else {
|
|
|
- endTime = new Date(nowMs + 3600000);
|
|
|
+ endTime = new Date(nowMs + HOUR_MS);
|
|
|
}
|
|
|
-
|
|
|
result.push({
|
|
|
item,
|
|
|
estimatedStart: start,
|
|
|
@@ -184,203 +124,359 @@ export function QueueTimelineView({
|
|
|
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') {
|
|
|
- const pid = item.printer_id;
|
|
|
- if (!pendingByPrinter.has(pid)) pendingByPrinter.set(pid, []);
|
|
|
- pendingByPrinter.get(pid)!.push(item);
|
|
|
+ // 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);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // Chain pending items per printer
|
|
|
- for (const [printerId, items] of pendingByPrinter) {
|
|
|
+ const sixMonthsFromNow = Date.now() + 180 * 24 * HOUR_MS;
|
|
|
+ for (const [lk, items] of pendingByLaneKey) {
|
|
|
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());
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
+ 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) {
|
|
|
- // 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 scheduled = parseUTCDate(item.scheduled_time);
|
|
|
+ if (scheduled && scheduled.getTime() <= sixMonthsFromNow) {
|
|
|
+ chainEnd = Math.max(chainEnd, scheduled.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,
|
|
|
+ estimatedStart: new Date(chainEnd),
|
|
|
+ estimatedEnd: new Date(chainEnd + duration),
|
|
|
type: 'queued',
|
|
|
});
|
|
|
-
|
|
|
- chainEnd = endTime.getTime();
|
|
|
+ chainEnd += duration;
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- // 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;
|
|
|
+ // 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<LaneDescriptor[]>(() => {
|
|
|
+ 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<string>();
|
|
|
+ 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]);
|
|
|
|
|
|
- // Filter by type
|
|
|
- if (filter === 'printing') return ev.type === 'printing';
|
|
|
- if (filter === 'queued') return ev.type === 'queued';
|
|
|
- return true;
|
|
|
- });
|
|
|
- }, [events, viewDayStart, viewDayEnd, filter]);
|
|
|
+ const eventsByLane = useMemo(() => {
|
|
|
+ const map = new Map<string, ScheduleEvent[]>();
|
|
|
+ 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]);
|
|
|
|
|
|
- // Group events by hour for time markers
|
|
|
- const groupedByHour = useMemo(() => {
|
|
|
- const groups: Map<number, ScheduleEvent[]> = new Map();
|
|
|
- for (const ev of filteredEvents) {
|
|
|
- const hour = ev.estimatedEnd.getHours();
|
|
|
- if (!groups.has(hour)) groups.set(hour, []);
|
|
|
- groups.get(hour)!.push(ev);
|
|
|
+ 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)),
|
|
|
+ });
|
|
|
}
|
|
|
- // Sort by hour
|
|
|
- return Array.from(groups.entries()).sort(([a], [b]) => a - b);
|
|
|
- }, [filteredEvents]);
|
|
|
+ return ticks;
|
|
|
+ }, [rangeStartMs]);
|
|
|
|
|
|
- // 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;
|
|
|
+ const nowPct = ((nowMs - rangeStartMs) / RANGE_MS) * 100;
|
|
|
+ const nowInView = nowPct >= 0 && nowPct <= 100;
|
|
|
|
|
|
- // Overall completion estimate
|
|
|
+ // 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());
|
|
|
- }
|
|
|
+ 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 },
|
|
|
- ];
|
|
|
+ const trackRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
return (
|
|
|
<div>
|
|
|
- {/* Header */}
|
|
|
+ {/* Window controls */}
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
|
|
|
- {/* Day navigation */}
|
|
|
<div className="flex items-center gap-2">
|
|
|
- <Button variant="ghost" size="sm" onClick={goPrev} className="p-1.5">
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => setWindowOffsetMs((v) => v - 12 * HOUR_MS)}
|
|
|
+ className="p-1.5"
|
|
|
+ title={t('queue.timeline.window.back12h')}
|
|
|
+ >
|
|
|
<ChevronLeft className="w-4 h-4" />
|
|
|
</Button>
|
|
|
- <span className="text-sm font-medium text-white min-w-[140px] text-center">
|
|
|
- {formatDateLabel(viewDate)}
|
|
|
+ <span className="text-sm font-medium text-white min-w-[180px] text-center">
|
|
|
+ {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' })}
|
|
|
</span>
|
|
|
- <Button variant="ghost" size="sm" onClick={goNext} className="p-1.5">
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => setWindowOffsetMs((v) => v + 12 * HOUR_MS)}
|
|
|
+ className="p-1.5"
|
|
|
+ title={t('queue.timeline.window.forward12h')}
|
|
|
+ >
|
|
|
<ChevronRight className="w-4 h-4" />
|
|
|
</Button>
|
|
|
- {!isToday && (
|
|
|
- <Button variant="ghost" size="sm" onClick={goToday} className="text-xs text-bambu-green">
|
|
|
- {t('queue.timeline.day.today')}
|
|
|
+ {windowOffsetMs !== 0 && (
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => setWindowOffsetMs(0)}
|
|
|
+ className="text-xs text-bambu-green"
|
|
|
+ >
|
|
|
+ {t('queue.timeline.window.now')}
|
|
|
</Button>
|
|
|
)}
|
|
|
</div>
|
|
|
-
|
|
|
{allDoneBy && (
|
|
|
<span className="text-xs text-bambu-gray flex items-center gap-1.5">
|
|
|
<Clock className="w-3.5 h-3.5" />
|
|
|
{t('queue.timeline.allDoneBy', {
|
|
|
- time: allDoneBy.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }),
|
|
|
+ time: allDoneBy.toLocaleString(undefined, {
|
|
|
+ weekday: 'short',
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit',
|
|
|
+ }),
|
|
|
})}
|
|
|
</span>
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
- {/* Filter tabs */}
|
|
|
- <div className="flex gap-2 mb-5">
|
|
|
- {filterTabs.map((tab) => (
|
|
|
- <button
|
|
|
- key={tab.key}
|
|
|
- onClick={() => setFilter(tab.key)}
|
|
|
- className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
|
|
- filter === tab.key
|
|
|
- ? 'bg-bambu-green text-white'
|
|
|
- : 'bg-bambu-dark-secondary border border-bambu-dark-tertiary text-bambu-gray hover:text-white'
|
|
|
- }`}
|
|
|
- >
|
|
|
- {tab.label}
|
|
|
- {tab.count > 0 && (
|
|
|
- <span className={`ml-1.5 text-xs ${filter === tab.key ? 'text-white/70' : 'text-bambu-gray'}`}>
|
|
|
- {tab.count}
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </button>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Schedule feed */}
|
|
|
- {groupedByHour.length > 0 ? (
|
|
|
- <div className="space-y-6">
|
|
|
- {groupedByHour.map(([hour, hourEvents]) => (
|
|
|
- <div key={hour}>
|
|
|
- {/* Hour marker */}
|
|
|
- <div className="flex items-center gap-3 mb-3">
|
|
|
- <span className="text-xs font-medium text-bambu-gray w-14 shrink-0">
|
|
|
- {getHourLabel(hour)}
|
|
|
- </span>
|
|
|
- <div className="flex-1 h-px bg-bambu-dark-tertiary" />
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Events in this hour */}
|
|
|
- <div className="space-y-2 sm:ml-[68px]">
|
|
|
- {hourEvents.map((event) => (
|
|
|
- <ScheduleCard
|
|
|
- key={event.item.id}
|
|
|
- event={event}
|
|
|
- now={now}
|
|
|
- onItemClick={onItemClick}
|
|
|
- t={t}
|
|
|
- />
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- ))}
|
|
|
+ {/* 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 && (
|
|
|
+ <div className="mb-4 p-3 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 text-xs text-bambu-gray">
|
|
|
+ {t('queue.timeline.nothingCommitted')}
|
|
|
</div>
|
|
|
- ) : (
|
|
|
+ )}
|
|
|
+
|
|
|
+ {lanes.length === 0 ? (
|
|
|
<div className="flex flex-col items-center justify-center py-16 text-bambu-gray">
|
|
|
<Layers className="w-12 h-12 mb-3 opacity-30" />
|
|
|
<p className="text-sm">{t('queue.timeline.noData')}</p>
|
|
|
</div>
|
|
|
+ ) : (
|
|
|
+ <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary overflow-hidden">
|
|
|
+ {/* Hour axis */}
|
|
|
+ <div className="flex border-b border-bambu-dark-tertiary">
|
|
|
+ <div className="w-32 sm:w-40 shrink-0 px-3 py-2 text-xs font-medium text-bambu-gray border-r border-bambu-dark-tertiary">
|
|
|
+ {t('queue.timeline.printerColumnHeader')}
|
|
|
+ </div>
|
|
|
+ <div className="relative flex-1 h-9">
|
|
|
+ {hourTicks.map((tick) => (
|
|
|
+ <div
|
|
|
+ key={tick.ms}
|
|
|
+ className="absolute top-0 bottom-0 border-l border-bambu-dark-tertiary/40 text-[10px] sm:text-xs text-bambu-gray pl-1 flex items-center"
|
|
|
+ style={{ left: `${tick.pct}%` }}
|
|
|
+ >
|
|
|
+ {tick.label}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Lanes */}
|
|
|
+ <div ref={trackRef} className="relative">
|
|
|
+ {lanes.map((lane) => {
|
|
|
+ const laneEvents = eventsByLane.get(lane.key) ?? [];
|
|
|
+ return (
|
|
|
+ <div key={lane.key} className="flex border-b border-bambu-dark-tertiary/40 last:border-b-0">
|
|
|
+ <div className="w-32 sm:w-40 shrink-0 px-3 py-3 border-r border-bambu-dark-tertiary flex items-center gap-2">
|
|
|
+ <PrinterIcon className={`w-3.5 h-3.5 shrink-0 ${
|
|
|
+ lane.printerId == null && lane.targetModel == null
|
|
|
+ ? 'text-orange-400'
|
|
|
+ : lane.targetModel
|
|
|
+ ? 'text-blue-400'
|
|
|
+ : 'text-bambu-green'
|
|
|
+ }`} />
|
|
|
+ <span className="text-sm text-white truncate">{lane.label}</span>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ className="relative flex-1"
|
|
|
+ style={{ height: LANE_BAR_HEIGHT_PX + 16 }}
|
|
|
+ >
|
|
|
+ {/* Hour grid lines */}
|
|
|
+ {hourTicks.map((tick) => (
|
|
|
+ <div
|
|
|
+ key={tick.ms}
|
|
|
+ className="absolute top-0 bottom-0 border-l border-bambu-dark-tertiary/30"
|
|
|
+ style={{ left: `${tick.pct}%` }}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+
|
|
|
+ {/* Idle background — diagonal stripes hint that the lane
|
|
|
+ is sittable. Rendered behind bars so they overlay it. */}
|
|
|
+ <div
|
|
|
+ className="absolute inset-0 opacity-30"
|
|
|
+ style={{
|
|
|
+ backgroundImage:
|
|
|
+ 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(255,255,255,0.04) 6px, rgba(255,255,255,0.04) 12px)',
|
|
|
+ }}
|
|
|
+ aria-hidden
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* 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 (
|
|
|
+ <button
|
|
|
+ key={ev.item.id}
|
|
|
+ onClick={() => onItemClick(ev.item)}
|
|
|
+ title={tooltipParts}
|
|
|
+ className={`absolute rounded-md transition-all hover:brightness-110 hover:z-10 overflow-hidden flex items-center gap-1.5 px-1.5 text-left ${
|
|
|
+ isPrinting
|
|
|
+ ? 'bg-blue-500/30 border border-blue-400/60'
|
|
|
+ : isBatched
|
|
|
+ ? 'bg-cyan-500/20 border border-cyan-400/50'
|
|
|
+ : 'bg-bambu-green/20 border border-bambu-green/40'
|
|
|
+ }`}
|
|
|
+ style={{
|
|
|
+ left: `${leftPct}%`,
|
|
|
+ width: `max(${MIN_BAR_PX}px, ${widthPct}%)`,
|
|
|
+ top: 8,
|
|
|
+ height: LANE_BAR_HEIGHT_PX,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {thumbnailUrl && (
|
|
|
+ <img
|
|
|
+ src={thumbnailUrl}
|
|
|
+ alt=""
|
|
|
+ className="w-7 h-7 rounded object-cover shrink-0 bg-bambu-dark"
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ <div className="min-w-0 flex-1">
|
|
|
+ <div className="text-xs text-white font-medium truncate leading-tight">
|
|
|
+ {displayName}
|
|
|
+ </div>
|
|
|
+ <div className="text-[10px] text-bambu-gray truncate leading-tight">
|
|
|
+ {ev.item.print_time_seconds ? formatDuration(ev.item.print_time_seconds) : ''}
|
|
|
+ {isPrinting && ev.progress != null ? ` · ${Math.round(ev.progress)}%` : ''}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {isPrinting && ev.progress != null && (
|
|
|
+ <div
|
|
|
+ className="absolute bottom-0 left-0 h-0.5 bg-blue-300"
|
|
|
+ style={{ width: `${ev.progress}%` }}
|
|
|
+ aria-hidden
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+
|
|
|
+ {/* 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 && (
|
|
|
+ <div className="absolute top-0 bottom-0 pointer-events-none z-20 flex inset-x-0">
|
|
|
+ <div className="w-32 sm:w-40 shrink-0" />
|
|
|
+ <div className="relative flex-1">
|
|
|
+ <div
|
|
|
+ className="absolute top-0 bottom-0 w-0.5 bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.6)]"
|
|
|
+ style={{ left: `${nowPct}%` }}
|
|
|
+ >
|
|
|
+ <div className="absolute -top-1 -left-1 w-2.5 h-2.5 bg-red-400 rounded-full" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
)}
|
|
|
</div>
|
|
|
);
|