Add queue timeline view, visual refresh, and fix plate thumbnail auth

Queue page redesign: compact stats bar replaces summary cards, color-
  coded left borders for status scanning, collapsible history with
  condensed rows, and a new production schedule timeline view (#823)
  showing estimated completion times grouped by hour with filter tabs
  and day navigation. i18n keys added for all 7 languages.

  Fix plate thumbnails returning 401 in print modal when auth is enabled
  (missing stream token). Fix schedule calendar picker opening off-screen
  (hidden input positioned with sr-only instead of near the date field).
This commit is contained in:
maziggy
2026-03-28 13:45:37 +01:00
parent 24a5217d46
commit eaf0a1c281
20 changed files with 1318 additions and 570 deletions
+6
View File
@@ -5,14 +5,20 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.3b1] - Unreleased
### New Features
- **Queue Timeline View** ([#823](https://github.com/maziggy/bambuddy/issues/823)) — The queue page now has a production schedule view showing when each print is estimated to finish. Events are sorted chronologically and grouped by hour, with cards showing the file name, printer, estimated completion time, and time remaining. Active prints show a live progress bar. Filter by "Show All", "Printing", or "Queued", and navigate between days. Click any event to edit or stop it. Toggle between List and Timeline views with the button group above the queue. Requested by @sanjay2409.
- **Staggered Batch Start for Multi-Printer Jobs** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — When sending a print to multiple printers via the queue, you can now stagger the starts to avoid power spikes from simultaneous bed heating. Enable "Stagger printer starts" in the schedule options to define a group size (how many printers start at once) and interval (minutes between groups). For example, 10 printers with group size 2 and interval 5 min will start in 5 waves over 25 minutes. Default group size and interval are configurable in Settings → Queue. Works with both ASAP and Scheduled timing — ASAP starts the first group immediately, subsequent groups get computed scheduled times. Requested by @maziggy.
- **Settings Queue Tab** — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).
### Improved
- **Queue Page Visual Refresh** — Compact stats bar replaces the five summary cards (saves vertical space), color-coded left borders on all queue items for instant status scanning, collapsible history section (collapsed by default), and condensed single-line rows for history items showing more prints at a glance.
### Fixed
- **Filament Usage Not Recorded When Auto-Archive Disabled** — When a printer had "Auto-archive completed prints" turned off, filament consumption was silently lost. The `on_print_complete` callback returned early before reaching the usage tracking code, so neither the internal inventory (AMS remain% deltas) nor Spoolman received usage data. Moved filament tracking to run before the archive check so usage is always recorded regardless of the auto-archive setting.
- **H2D External Spool Uses Wrong Nozzle** ([#836](https://github.com/maziggy/bambuddy/issues/836)) — Prints sent from Bambuddy to dual-nozzle printers (H2D, H2D Pro) with external spools always routed to the wrong nozzle. The old `ams_mapping2` format used a shared `ams_id: 255` with `slot_id: 0/1` to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797 `ams_mapping2` format change (per-tray `ams_id` instead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly. Reported by @NoahTingey.
- **SpoolBuddy "Add to Inventory" Failed Silently** — The quick-add button on the SpoolBuddy kiosk did nothing when tapped. The scale weight was sent as a float but the backend requires an integer, causing a Pydantic validation error. The error was silently caught with no user feedback, leaving the confirmation modal stuck open. Fixed by rounding the weight before sending, moving the modal close to a `finally` block, and adding an error toast with the actual API message.
- **SpoolBuddy Dashboard Crash on Null Spool Fields** — Viewing a spool with null `subtype`, `brand`, `rgba`, or `color_name` on the SpoolBuddy dashboard crashed the UI (black screen). The spool prop construction used `displayedSpool?.subtype ?? sbState.matchedSpool!.subtype` — when the field was `null`, the `??` operator fell through to `sbState.matchedSpool` which could also be null, causing a TypeError. Fixed by picking one source object instead of mixing per-field fallbacks. Added a global React error boundary so future crashes show the error instead of a black screen.
- **Plate Thumbnails 401 in Print Modal** — Multi-plate 3MF plate thumbnails in the print modal returned 401 Unauthorized when authentication was enabled. The backend returns bare URL paths for plate thumbnails, but the `PlateSelector` component used them directly in `<img src>` without appending the stream token. Fixed by passing the URL through `withStreamToken()`.
- **Schedule Calendar Picker Opens Off-Screen** — Clicking the calendar icon in the print modal's scheduled mode opened the native date picker at the bottom of the viewport instead of near the date field. The hidden `datetime-local` input used `sr-only` positioning which anchored the picker off-screen. Fixed by positioning the hidden input inside the date field's container.
- **SpoolBuddy Kiosk Display Blanking and Crashes** — The kiosk Chromium flags added in 0.2.2.2 caused display instability: `--js-flags=--max-old-space-size=128` crashed the V8 renderer when heap exceeded 128 MB, `--enable-low-end-device-mode` aggressively killed GPU rendering surfaces, and resetting `CHROMIUM_FLAGS` discarded the Pi's GPU defaults (`--enable-gpu-rasterization`, ANGLE/GLES) creating an unstable mixed CPU/GPU rendering path. Fixed by removing both flags, appending kiosk flags to Pi defaults instead of replacing them, adding a `wlr-randr` keep-alive loop to prevent display blanking, and adding `<screenBlankTimeout>0</screenBlankTimeout>` to the labwc config.
## [0.2.2.2] - 2026-03-27
+1 -1
View File
@@ -104,7 +104,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
### ⏰ Scheduling & Automation
- **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
- Print queue with drag-and-drop
- Print queue with drag-and-drop and timeline schedule view
- Multi-printer selection (send to multiple printers at once)
- Staggered batch start (start printers in groups with configurable interval to avoid power spikes)
- Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
@@ -2,7 +2,7 @@
* Tests for the QueuePage component.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
@@ -110,6 +110,13 @@ const mockPrinters = [
describe('QueuePage', () => {
beforeEach(() => {
// Mock localStorage.getItem to return expected defaults for queue page
vi.mocked(localStorage.getItem).mockImplementation((key: string) => {
if (key === 'queue.historyCollapsed') return 'false'; // expanded
if (key === 'queue.viewMode') return 'list';
return null;
});
// Setup MSW handlers for this test
server.use(
http.get('/api/v1/queue/', () => {
@@ -0,0 +1,120 @@
import {
CheckCircle,
XCircle,
SkipForward,
X,
RefreshCw,
Trash2,
Printer,
Timer,
Layers,
} from 'lucide-react';
import { api } from '../api/client';
import { type TimeFormat, formatDuration, formatRelativeTime } from '../utils/date';
import type { PrintQueueItem, Permission } from '../api/client';
import { Button } from './Button';
const STATUS_CONFIG = {
completed: { icon: CheckCircle, color: 'text-emerald-400', border: 'border-l-emerald-500' },
failed: { icon: XCircle, color: 'text-red-400', border: 'border-l-red-500' },
skipped: { icon: SkipForward, color: 'text-orange-400', border: 'border-l-gray-500' },
cancelled: { icon: X, color: 'text-gray-400', border: 'border-l-gray-500' },
} as const;
export function CompactHistoryRow({
item,
onRequeue,
onRemove,
timeFormat = 'system',
hasPermission,
canModify,
t,
}: {
item: PrintQueueItem;
onRequeue: () => void;
onRemove: () => void;
timeFormat?: TimeFormat;
hasPermission: (permission: Permission) => boolean;
canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const config = STATUS_CONFIG[item.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.cancelled;
const StatusIcon = config.icon;
const displayName = item.archive_name || item.library_file_name || `File #${item.archive_id || item.library_file_id}`;
const thumbnailUrl = item.archive_thumbnail
? api.getArchiveThumbnail(item.archive_id!)
: item.library_file_thumbnail
? api.getLibraryFileThumbnailUrl(item.library_file_id!)
: null;
const completedTime = item.completed_at || item.created_at;
return (
<div className={`flex items-center gap-2 sm:gap-3 px-3 py-2 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary border-l-[3px] ${config.border}`}>
{/* Status icon */}
<StatusIcon className={`w-4 h-4 shrink-0 ${config.color}`} />
{/* Thumbnail */}
<div className="w-8 h-8 shrink-0 bg-bambu-dark rounded 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-4 h-4" />
</div>
)}
</div>
{/* File name */}
<span className="text-sm text-white font-medium truncate min-w-0 flex-1">
{displayName}
</span>
{/* Printer */}
{item.printer_name && (
<span className="hidden sm:flex items-center gap-1 text-xs text-bambu-gray shrink-0">
<Printer className="w-3 h-3" />
<span className="truncate max-w-[100px]">{item.printer_name}</span>
</span>
)}
{/* Duration */}
{item.print_time_seconds && (
<span className="hidden sm:flex items-center gap-1 text-xs text-bambu-gray shrink-0">
<Timer className="w-3 h-3" />
{formatDuration(item.print_time_seconds)}
</span>
)}
{/* Completed time */}
<span className="text-xs text-bambu-gray shrink-0">
{formatRelativeTime(completedTime, timeFormat, t)}
</span>
{/* Actions */}
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={onRequeue}
disabled={!hasPermission('queue:create')}
title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10 p-1.5"
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onRemove}
disabled={!canModify('queue', 'delete', item.created_by_id)}
title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
className="p-1.5"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
);
}
@@ -2,6 +2,7 @@ import { Layers, Check, AlertTriangle, Square, CheckSquare } from 'lucide-react'
import { useTranslation } from 'react-i18next';
import type { PlateSelectorProps } from './types';
import { formatDuration } from '../../utils/date';
import { withStreamToken } from '../../api/client';
/**
* Plate selection grid for multi-plate 3MF files.
@@ -75,7 +76,7 @@ export function PlateSelector({
)}
{plate.has_thumbnail && plate.thumbnail_url != null ? (
<img
src={plate.thumbnail_url}
src={withStreamToken(plate.thumbnail_url)}
alt={`Plate ${plate.index}`}
className="w-10 h-10 rounded object-cover bg-bambu-dark-tertiary"
/>
@@ -188,6 +188,15 @@ export function ScheduleOptionsPanel({
>
<Calendar className="w-4 h-4" />
</button>
{/* Hidden datetime-local anchored here so the native picker opens near the date field */}
<input
ref={hiddenInputRef}
type="datetime-local"
className="absolute top-0 left-0 w-0 h-0 opacity-0 pointer-events-none"
value={options.scheduledTime}
onChange={handleCalendarChange}
tabIndex={-1}
/>
</div>
{/* Time input */}
<div className="w-32">
@@ -204,15 +213,6 @@ export function ScheduleOptionsPanel({
/>
</div>
</div>
{/* Hidden datetime-local for calendar picker */}
<input
ref={hiddenInputRef}
type="datetime-local"
className="sr-only"
value={options.scheduledTime}
onChange={handleCalendarChange}
tabIndex={-1}
/>
{(!isDateValid || !isTimeValid) && (
<p className="mt-1 text-xs text-red-400">
Please enter a valid date and time
+46
View File
@@ -0,0 +1,46 @@
import { Play, Clock, Timer, Weight, CheckCircle } from 'lucide-react';
import { formatDuration } from '../utils/date';
function formatWeight(g: number): string {
if (g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
return `${Math.round(g)}g`;
}
export function QueueStatsBar({
activeCount,
pendingCount,
totalTime,
totalWeight,
historyCount,
t,
}: {
activeCount: number;
pendingCount: number;
totalTime: number;
totalWeight: number;
historyCount: number;
t: (key: string) => string;
}) {
const stats = [
{ icon: Play, value: activeCount, label: t('queue.summary.printing'), color: 'text-blue-400' },
{ icon: Clock, value: pendingCount, label: t('queue.summary.queued'), color: 'text-yellow-400' },
{ icon: Timer, value: formatDuration(totalTime), label: t('queue.summary.totalTime'), color: 'text-bambu-green' },
{ icon: Weight, value: formatWeight(totalWeight), label: t('queue.summary.totalWeight'), color: 'text-purple-400' },
{ icon: CheckCircle, value: historyCount, label: t('queue.summary.history'), color: 'text-bambu-gray' },
];
return (
<div className="flex items-center gap-3 sm:gap-5 flex-wrap px-4 py-3 bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary mb-6">
{stats.map((stat, i) => (
<div key={i} className="flex items-center gap-3">
{i > 0 && <span className="hidden sm:block text-bambu-dark-tertiary">|</span>}
<div className="flex items-center gap-1.5">
<stat.icon className={`w-4 h-4 ${stat.color}`} />
<span className="text-sm font-semibold text-white">{stat.value}</span>
<span className="text-xs sm:text-sm text-bambu-gray">{stat.label}</span>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,387 @@
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<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;
}
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, 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,
printerStatuses,
onItemClick,
t,
}: QueueTimelineViewProps) {
const [viewDate, setViewDate] = useState(() => getStartOfDay(new Date()));
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);
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<number | null, PrintQueueItem[]>();
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<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);
}
// 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 (
<div>
{/* Header */}
<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">
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium text-white min-w-[140px] text-center">
{formatDateLabel(viewDate)}
</span>
<Button variant="ghost" size="sm" onClick={goNext} className="p-1.5">
<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')}
</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' }),
})}
</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>
))}
</div>
) : (
<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>
);
}
+23
View File
@@ -944,6 +944,29 @@ export default {
bulkCancelled: '{{count}} Element(e) abgebrochen',
bulkCancelFailed: 'Elemente konnten nicht abgebrochen werden',
},
// Timeline view
timeline: {
listView: 'Liste',
timelineView: 'Zeitstrahl',
unassigned: 'Nicht zugewiesen',
noData: 'Keine geplanten Drucke für diesen Tag',
allDoneBy: 'Alle Drucke voraussichtlich fertig um {{time}}',
staged: 'Bereitgestellt',
filterAll: 'Alle anzeigen',
filterPrinting: 'Druckend',
filterQueued: 'Warteschlange',
time: {
anyMoment: 'jeden Moment',
minutesLeft: '{{minutes}}m übrig',
hoursLeft: '{{hours}}h übrig',
hoursMinutesLeft: '{{hours}}h {{minutes}}m übrig',
},
day: {
previous: 'Vorheriger Tag',
next: 'Nächster Tag',
today: 'Heute',
},
},
// Permissions
permissions: {
noStopPrint: 'Sie haben keine Berechtigung, Drucke zu stoppen',
+23
View File
@@ -944,6 +944,29 @@ export default {
bulkCancelled: 'Cancelled {{count}} item(s)',
bulkCancelFailed: 'Failed to cancel items',
},
// Timeline view
timeline: {
listView: 'List',
timelineView: 'Timeline',
unassigned: 'Unassigned',
noData: 'No scheduled prints for this day',
allDoneBy: 'All prints estimated done by {{time}}',
staged: 'Staged',
filterAll: 'Show All',
filterPrinting: 'Printing',
filterQueued: 'Queued',
time: {
anyMoment: 'any moment',
minutesLeft: '{{minutes}}m left',
hoursLeft: '{{hours}}h left',
hoursMinutesLeft: '{{hours}}h {{minutes}}m left',
},
day: {
previous: 'Previous day',
next: 'Next day',
today: 'Today',
},
},
// Permissions
permissions: {
noStopPrint: 'You do not have permission to stop prints',
+23
View File
@@ -944,6 +944,29 @@ export default {
bulkCancelled: '{{count}} éléments annulés',
bulkCancelFailed: 'Échec annulation',
},
// Timeline view
timeline: {
listView: 'Liste',
timelineView: 'Chronologie',
unassigned: 'Non attribué',
noData: 'Aucune impression planifiée pour ce jour',
allDoneBy: 'Toutes les impressions terminées vers {{time}}',
staged: 'En attente',
filterAll: 'Tout afficher',
filterPrinting: 'En cours',
filterQueued: 'En file',
time: {
anyMoment: 'imminent',
minutesLeft: '{{minutes}}m restantes',
hoursLeft: '{{hours}}h restantes',
hoursMinutesLeft: '{{hours}}h {{minutes}}m restantes',
},
day: {
previous: 'Jour précédent',
next: 'Jour suivant',
today: 'Aujourd\'hui',
},
},
// Permissions
permissions: {
noStopPrint: 'Pas d\'autorisation d\'arrêt',
+23
View File
@@ -944,6 +944,29 @@ export default {
bulkCancelled: 'Annullati {{count}} elementi',
bulkCancelFailed: 'Annullamento elementi non riuscito',
},
// Timeline view
timeline: {
listView: 'Lista',
timelineView: 'Cronologia',
unassigned: 'Non assegnato',
noData: 'Nessuna stampa programmata per questo giorno',
allDoneBy: 'Tutte le stampe completate entro le {{time}}',
staged: 'In attesa',
filterAll: 'Mostra tutto',
filterPrinting: 'In stampa',
filterQueued: 'In coda',
time: {
anyMoment: 'a momenti',
minutesLeft: '{{minutes}}m rimanenti',
hoursLeft: '{{hours}}h rimanenti',
hoursMinutesLeft: '{{hours}}h {{minutes}}m rimanenti',
},
day: {
previous: 'Giorno precedente',
next: 'Giorno successivo',
today: 'Oggi',
},
},
// Permissions
permissions: {
noStopPrint: 'Non hai il permesso di fermare stampe',
+23
View File
@@ -943,6 +943,29 @@ export default {
bulkCancelled: '{{count}}件のアイテムをキャンセルしました',
bulkCancelFailed: 'アイテムのキャンセルに失敗しました',
},
// Timeline view
timeline: {
listView: 'リスト',
timelineView: 'タイムライン',
unassigned: '未割当',
noData: 'この日の予定された印刷はありません',
allDoneBy: 'すべての印刷は {{time}} までに完了予定',
staged: 'ステージング',
filterAll: 'すべて表示',
filterPrinting: '印刷中',
filterQueued: '待機中',
time: {
anyMoment: 'まもなく',
minutesLeft: '残り{{minutes}}分',
hoursLeft: '残り{{hours}}時間',
hoursMinutesLeft: '残り{{hours}}時間{{minutes}}分',
},
day: {
previous: '前日',
next: '翌日',
today: '今日',
},
},
// Permissions
permissions: {
noStopPrint: '印刷を停止する権限がありません',
+23
View File
@@ -944,6 +944,29 @@ export default {
bulkCancelled: 'Cancelado {{count}} item(s)',
bulkCancelFailed: 'Falha ao cancelar itens',
},
// Timeline view
timeline: {
listView: 'Lista',
timelineView: 'Linha do tempo',
unassigned: 'Não atribuído',
noData: 'Nenhuma impressão agendada para este dia',
allDoneBy: 'Todas as impressões concluídas até {{time}}',
staged: 'Preparado',
filterAll: 'Mostrar tudo',
filterPrinting: 'Imprimindo',
filterQueued: 'Na fila',
time: {
anyMoment: 'a qualquer momento',
minutesLeft: '{{minutes}}m restantes',
hoursLeft: '{{hours}}h restantes',
hoursMinutesLeft: '{{hours}}h {{minutes}}m restantes',
},
day: {
previous: 'Dia anterior',
next: 'Próximo dia',
today: 'Hoje',
},
},
// Permissions
permissions: {
noStopPrint: 'Você não tem permissão para parar impressões',
+23
View File
@@ -944,6 +944,29 @@ export default {
bulkCancelled: '已取消 {{count}} 个项目',
bulkCancelFailed: '批量取消项目失败',
},
// Timeline view
timeline: {
listView: '列表',
timelineView: '时间线',
unassigned: '未分配',
noData: '当天没有计划的打印任务',
allDoneBy: '所有打印预计在 {{time}} 前完成',
staged: '暂存',
filterAll: '全部显示',
filterPrinting: '打印中',
filterQueued: '排队中',
time: {
anyMoment: '即将完成',
minutesLeft: '剩余{{minutes}}分钟',
hoursLeft: '剩余{{hours}}小时',
hoursMinutesLeft: '剩余{{hours}}小时{{minutes}}分钟',
},
day: {
previous: '前一天',
next: '后一天',
today: '今天',
},
},
// Permissions
permissions: {
noStopPrint: '您没有停止打印的权限',
+133 -115
View File
@@ -48,16 +48,23 @@ import {
User,
Pause,
Weight,
ChevronDown,
ChevronRight,
List,
GanttChart,
} from 'lucide-react';
import { api } from '../api/client';
import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Card } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { PrintModal } from '../components/PrintModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { QueueStatsBar } from '../components/QueueStatsBar';
import { CompactHistoryRow } from '../components/CompactHistoryRow';
import { QueueTimelineView } from '../components/QueueTimelineView';
function formatWeight(g: number, useKg = false): string {
if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
@@ -361,6 +368,13 @@ function SortableQueueItem({
style={style}
className={`
group relative bg-bambu-dark-secondary rounded-xl border transition-all duration-200
border-l-[3px] ${
isPrinting ? 'border-l-blue-500' :
isPending ? 'border-l-yellow-500' :
item.status === 'completed' ? 'border-l-emerald-500' :
item.status === 'failed' ? 'border-l-red-500' :
'border-l-gray-500'
}
${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
${isSelected && isMobileSelectable ? 'sm:border-bambu-dark-tertiary border-bambu-green/40' : ''}
@@ -698,6 +712,12 @@ export function QueuePage() {
const saved = localStorage.getItem('queue.pendingSortAsc');
return saved !== null ? saved === 'true' : true;
});
const [historyCollapsed, setHistoryCollapsed] = useState(() => {
return localStorage.getItem('queue.historyCollapsed') !== 'false';
});
const [viewMode, setViewMode] = useState<'list' | 'timeline'>(() => {
return (localStorage.getItem('queue.viewMode') as 'list' | 'timeline') || 'list';
});
// Persist sort settings to localStorage
useEffect(() => {
@@ -716,6 +736,14 @@ export function QueuePage() {
localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc));
}, [pendingSortAsc]);
useEffect(() => {
localStorage.setItem('queue.historyCollapsed', String(historyCollapsed));
}, [historyCollapsed]);
useEffect(() => {
localStorage.setItem('queue.viewMode', viewMode);
}, [viewMode]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
@@ -940,6 +968,22 @@ export function QueuePage() {
return map;
}, [activePrinterIds, printerStatusQueries]);
// Build a map of printer_id -> full status for timeline view
const printerStatusMap = useMemo(() => {
const map: Record<number, { progress?: number; remaining_time?: number; state?: string }> = {};
activePrinterIds.forEach((printerId, index) => {
const result = printerStatusQueries[index];
if (result?.data) {
map[printerId] = {
progress: result.data.progress ?? undefined,
remaining_time: result.data.remaining_time ?? undefined,
state: result.data.state ?? undefined,
};
}
});
return map;
}, [activePrinterIds, printerStatusQueries]);
const historyItems = useMemo(() => {
let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
if (filterLocation) {
@@ -1001,78 +1045,15 @@ export function QueuePage() {
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2 sm:gap-3 lg:gap-4 mb-8">
<Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
<CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-blue-500/20 flex items-center justify-center shrink-0">
<Play className="w-4 h-4 sm:w-5 sm:h-5 text-blue-400" />
</div>
<div className="min-w-0">
<p className="text-xl sm:text-2xl font-bold text-white truncate">{activeItems.length}</p>
<p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.printing')}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-yellow-500/10 to-transparent border-yellow-500/20">
<CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center shrink-0">
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-400" />
</div>
<div className="min-w-0">
<p className="text-xl sm:text-2xl font-bold text-white truncate">{pendingItems.length}</p>
<p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.queued')}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-bambu-green/10 to-transparent border-bambu-green/20">
<CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-bambu-green/20 flex items-center justify-center shrink-0">
<Timer className="w-4 h-4 sm:w-5 sm:h-5 text-bambu-green" />
</div>
<div className="min-w-0">
<p className="text-xl sm:text-2xl font-bold text-white truncate">{formatDuration(totalQueueTime)}</p>
<p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.totalTime')}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
<CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
<Weight className="w-4 h-4 sm:w-5 sm:h-5 text-purple-500" />
</div>
<div className="min-w-0">
<p className="text-xl sm:text-2xl font-bold text-white truncate">{formatWeight(totalWeight)}</p>
<p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.totalWeight')}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="col-span-2 sm:col-span-1 bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
<CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-lg bg-gray-500/20 flex items-center justify-center shrink-0">
<CheckCircle className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400" />
</div>
<div className="min-w-0">
<p className="text-xl sm:text-2xl font-bold text-white truncate">{historyItems.length}</p>
<p className="text-xs sm:text-sm text-bambu-gray truncate">{t('queue.summary.history')}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Summary Stats */}
<QueueStatsBar
activeCount={activeItems.length}
pendingCount={pendingItems.length}
totalTime={totalQueueTime}
totalWeight={totalWeight}
historyCount={historyItems.length}
t={t}
/>
{/* Filters */}
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
@@ -1137,6 +1118,26 @@ export function QueuePage() {
)}
</div>
{/* View Mode Toggle */}
<div className="hidden sm:flex items-center gap-3 mb-6">
<div className="flex items-center border border-bambu-dark-tertiary rounded-lg overflow-hidden">
<button
className={`p-2 transition-colors ${viewMode === 'list' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('list')}
title={t('queue.timeline.listView')}
>
<List className="w-4 h-4" />
</button>
<button
className={`p-2 transition-colors ${viewMode === 'timeline' ? 'bg-bambu-green text-white' : 'bg-bambu-dark text-bambu-gray hover:text-white'}`}
onClick={() => setViewMode('timeline')}
title={t('queue.timeline.timelineView')}
>
<GanttChart className="w-4 h-4" />
</button>
</div>
</div>
{isLoading ? (
<div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
) : queue?.length === 0 ? (
@@ -1147,6 +1148,21 @@ export function QueuePage() {
{t('queue.empty.description')}
</p>
</Card>
) : viewMode === 'timeline' ? (
<QueueTimelineView
queueItems={queue || []}
printerStatuses={printerStatusMap}
onItemClick={(item) => {
if (['completed', 'failed', 'skipped', 'cancelled'].includes(item.status)) {
setRequeueItem(item);
} else if (item.status === 'pending') {
setEditItem(item);
} else if (item.status === 'printing') {
setConfirmAction({ type: 'stop', item });
}
}}
t={t}
/>
) : (
<div className="space-y-6 sm:space-y-8">
{/* Active Prints */}
@@ -1301,53 +1317,55 @@ export function QueuePage() {
{historyItems.length > 0 && (
<div>
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 sm:mb-4">
<h2 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<CheckCircle className="w-4 h-4 sm:w-5 sm:h-5 text-bambu-gray" />
<button
onClick={() => setHistoryCollapsed(!historyCollapsed)}
className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 hover:text-bambu-green transition-colors"
>
{historyCollapsed ? <ChevronRight className="w-4 h-4 sm:w-5 sm:h-5" /> : <ChevronDown className="w-4 h-4 sm:w-5 sm:h-5" />}
{t('queue.sections.history')}
<span className="text-xs sm:text-sm font-normal text-bambu-gray">
({t('queue.itemCount', { count: historyItems.length })})
</span>
</h2>
<div className="flex items-center gap-2">
<select
className="px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={historySortBy}
onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
>
<option value="date">{t('queue.sort.byDate')}</option>
<option value="name">{t('queue.sort.byName')}</option>
<option value="printer">{t('queue.sort.byPrinter')}</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={() => setHistorySortAsc(!historySortAsc)}
title={historySortAsc ? t('queue.sort.ascendingOldest') : t('queue.sort.descendingNewest')}
className="px-2"
>
{historySortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
</Button>
</button>
{!historyCollapsed && (
<div className="flex items-center gap-2">
<select
className="px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={historySortBy}
onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
>
<option value="date">{t('queue.sort.byDate')}</option>
<option value="name">{t('queue.sort.byName')}</option>
<option value="printer">{t('queue.sort.byPrinter')}</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={() => setHistorySortAsc(!historySortAsc)}
title={historySortAsc ? t('queue.sort.ascendingOldest') : t('queue.sort.descendingNewest')}
className="px-2"
>
{historySortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
</Button>
</div>
)}
</div>
{!historyCollapsed && (
<div className="space-y-1.5 sm:space-y-2">
{historyItems.slice(0, 50).map((item) => (
<CompactHistoryRow
key={item.id}
item={item}
onRemove={() => setConfirmAction({ type: 'remove', item })}
onRequeue={() => setRequeueItem(item)}
timeFormat={timeFormat}
hasPermission={hasPermission}
canModify={canModify}
t={t}
/>
))}
</div>
</div>
<div className="space-y-2 sm:space-y-3">
{historyItems.slice(0, 20).map((item, index) => (
<SortableQueueItem
key={item.id}
item={item}
position={index + 1}
onEdit={() => {}}
onCancel={() => {}}
onRemove={() => setConfirmAction({ type: 'remove', item })}
onStop={() => {}}
onRequeue={() => setRequeueItem(item)}
onStart={() => {}}
timeFormat={timeFormat}
hasPermission={hasPermission}
canModify={canModify}
t={t}
/>
))}
</div>
)}
</div>
)}
</div>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-Dn-c66XQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C0h3VoP7.css">
<script type="module" crossorigin src="/assets/index-DGfczUVD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B4zcncds.css">
</head>
<body>
<div id="root"></div>