Просмотр исходного кода

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).
maziggy 5 месяцев назад
Родитель
Сommit
eaf0a1c281

+ 6 - 0
CHANGELOG.md

@@ -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
README.md

@@ -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

+ 8 - 1
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -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/', () => {

+ 120 - 0
frontend/src/components/CompactHistoryRow.tsx

@@ -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 - 1
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -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"
                 />

+ 9 - 9
frontend/src/components/PrintModal/ScheduleOptions.tsx

@@ -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 - 0
frontend/src/components/QueueStatsBar.tsx

@@ -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>
+  );
+}

+ 387 - 0
frontend/src/components/QueueTimelineView.tsx

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/fr.ts

@@ -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 - 0
frontend/src/i18n/locales/it.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -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 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -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
frontend/src/pages/QueuePage.tsx

@@ -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>
-                </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}
-                  />
-                ))}
+                </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>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-B4zcncds.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-C0h3VoP7.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DGfczUVD.js


+ 2 - 2
static/index.html

@@ -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>

Некоторые файлы не были показаны из-за большого количества измененных файлов