Files
bambuddy/frontend/src/components/QueueTimelineView.tsx
T
maziggy 1d344a8536 fix(ui): resolve light-theme low-contrast semantic text app-wide (#1909)
The app was built dark-first, so hundreds of hardcoded Tailwind semantic
text/icon utilities at light shades (text-amber-400, text-blue-300, ...) had
no dark: variant. With darkMode:'class' they applied in light theme too,
producing washed-out text on pale tints and white cards — including the three
reported spots (AMS Drying banner, Archives no-3MF warning, debug-logging
banner). Give each a theme-aware pair: a darker readable shade in light theme
with the original pinned to dark:, so dark theme is unchanged. ~100 files.

The bambu-* CSS-variable palette (self-correcting) and the dark-only SpoolBuddy
kiosk are left untouched. Plain text-white is already theme-aware via the
existing index.css .text-white override, so it needed no changes.
2026-07-07 09:54:10 +02:00

484 lines
20 KiB
TypeScript

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<number, { progress?: number; remaining_time?: number; state?: string }>;
onItemClick: (item: PrintQueueItem) => void;
t: (key: string, options?: Record<string, unknown>) => 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<ScheduleEvent[]>(() => {
const result: ScheduleEvent[] = [];
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) {
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<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]);
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]);
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<HTMLDivElement | null>(null);
return (
<div>
{/* Window controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
<div className="flex items-center gap-2">
<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-[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={() => setWindowOffsetMs((v) => v + 12 * HOUR_MS)}
className="p-1.5"
title={t('queue.timeline.window.forward12h')}
>
<ChevronRight className="w-4 h-4" />
</Button>
{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.toLocaleString(undefined, {
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
}),
})}
</span>
)}
</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-600 dark:text-orange-400'
: lane.targetModel
? 'text-blue-600 dark: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>
);
}