diff --git a/frontend/src/__tests__/pages/QueuePage.test.tsx b/frontend/src/__tests__/pages/QueuePage.test.tsx index f4261ce29..9b451d6dd 100644 --- a/frontend/src/__tests__/pages/QueuePage.test.tsx +++ b/frontend/src/__tests__/pages/QueuePage.test.tsx @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { render } from '../utils'; import { QueuePage } from '../../pages/QueuePage'; @@ -197,6 +197,154 @@ describe('QueuePage', () => { }); }); + it('shows one if-started-now ETA for an eligible pending item', async () => { + render(); + + const name = await screen.findByText('Test Print 1'); + const row = name.closest('.group'); + + expect(row).not.toBeNull(); + expect( + within(row as HTMLElement).getAllByTestId('queue-item-eta'), + ).toHaveLength(1); + }); + + it('shows one if-started-now ETA for a staged item', async () => { + server.use( + http.get('/api/v1/queue/', () => { + return HttpResponse.json([ + { + ...mockQueueItems[0], + archive_name: 'Staged Print', + manual_start: true, + }, + ]); + }), + ); + + render(); + + const name = await screen.findByText('Staged Print'); + const row = name.closest('.group'); + + expect(row).not.toBeNull(); + expect( + within(row as HTMLElement).getAllByTestId('queue-item-eta'), + ).toHaveLength(1); + }); + + it('shows exactly one live ETA for a printing item', async () => { + server.use( + http.get('/api/v1/printers/:id/status', ({ params }) => { + return HttpResponse.json({ + id: Number(params.id), + name: 'Test Printer', + connected: true, + state: 'RUNNING', + progress: 50, + remaining_time: 60, + layer_num: 50, + total_layers: 100, + filename: 'active.3mf', + }); + }), + ); + + render(); + + const name = await screen.findByText('Active Print'); + const row = name.closest('.group'); + + expect(row).not.toBeNull(); + + await waitFor(() => { + expect( + within(row as HTMLElement).getAllByText(/^ETA\s/), + ).toHaveLength(1); + }); + + expect( + within(row as HTMLElement).queryByTestId('queue-item-eta'), + ).not.toBeInTheDocument(); + }); + + it('does not show an ETA for a waiting item', async () => { + server.use( + http.get('/api/v1/queue/', () => { + return HttpResponse.json([ + { + ...mockQueueItems[0], + archive_name: 'Waiting Print', + waiting_reason: 'Waiting for matching printer', + }, + ]); + }), + ); + + render(); + + const name = await screen.findByText('Waiting Print'); + const row = name.closest('.group'); + + expect(row).not.toBeNull(); + expect( + within(row as HTMLElement).queryByTestId('queue-item-eta'), + ).not.toBeInTheDocument(); + }); + + it('does not show an if-started-now ETA for a scheduled item', async () => { + server.use( + http.get('/api/v1/queue/', () => { + return HttpResponse.json([ + { + ...mockQueueItems[0], + archive_name: 'Scheduled Print', + scheduled_time: new Date( + Date.now() + 5 * 60 * 60 * 1000, + ).toISOString(), + }, + ]); + }), + ); + + render(); + + const name = await screen.findByText('Scheduled Print'); + const row = name.closest('.group'); + + expect(row).not.toBeNull(); + expect( + within(row as HTMLElement).queryByTestId('queue-item-eta'), + ).not.toBeInTheDocument(); + }); + + it('does not render a dangling ETA for an invalid duration', async () => { + server.use( + http.get('/api/v1/queue/', () => { + return HttpResponse.json([ + { + ...mockQueueItems[0], + archive_name: 'Invalid Duration Print', + print_time_seconds: -60, + }, + ]); + }), + ); + + render(); + + const name = await screen.findByText('Invalid Duration Print'); + const row = name.closest('.group'); + + expect(row).not.toBeNull(); + expect( + within(row as HTMLElement).queryByTestId('queue-item-eta'), + ).not.toBeInTheDocument(); + expect( + within(row as HTMLElement).queryByText(/^ETA(?:\s|$)/), + ).not.toBeInTheDocument(); + }); + it('shows completed items in history', async () => { const user = userEvent.setup(); render(); diff --git a/frontend/src/__tests__/utils/queueEta.test.ts b/frontend/src/__tests__/utils/queueEta.test.ts deleted file mode 100644 index 807ac5950..000000000 --- a/frontend/src/__tests__/utils/queueEta.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { formatETA } from '../../utils/date'; -import { formatQueueItemETA } from '../../utils/queueEta'; - -describe('formatQueueItemETA', () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-07-31T18:00:00Z')); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('calculates the ETA from the current time and job duration', () => { - expect(formatQueueItemETA(90 * 60, '24h')).toBe( - formatETA(90, '24h'), - ); - }); - - it('returns null without a usable print duration', () => { - expect(formatQueueItemETA(null)).toBeNull(); - expect(formatQueueItemETA(undefined)).toBeNull(); - expect(formatQueueItemETA(0)).toBeNull(); - expect(formatQueueItemETA(-60)).toBeNull(); - }); -}); diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 02c984060..6c1579279 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -66,7 +66,6 @@ import { api, ApiError } from '../api/client'; import { PipelineRunsView } from './PipelineRunsPage'; import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date'; import { getBedTypeInfo } from '../utils/bedType'; -import { formatQueueItemETA } from '../utils/queueEta'; import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode } from '../api/client'; import { Card } from '../components/Card'; import { Button } from '../components/Button'; @@ -429,6 +428,17 @@ function SortableQueueItem({ const isPending = item.status === 'pending'; const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status); + // This is an "if started now" estimate, not a cumulative queue forecast. + // Do not show it for active, blocked, scheduled, or invalid queue items. + const queueItemEta = + isPending && + !item.waiting_reason && + !item.scheduled_time && + item.print_time_seconds != null && + item.print_time_seconds > 0 + ? formatETA(item.print_time_seconds / 60, timeFormat, t) + : null; + const isMobileSelectable = isPending && onToggleSelect; return ( @@ -599,20 +609,19 @@ function SortableQueueItem({ {item.print_time_seconds && ( - <> - - - {formatDuration(item.print_time_seconds)} - - {!item.waiting_reason && ( - - ETA {formatQueueItemETA(item.print_time_seconds, timeFormat, t)} - - )} - + + + {formatDuration(item.print_time_seconds)} + + )} + {queueItemEta && ( + + ETA {queueItemEta} + )} {item.filament_used_grams && ( diff --git a/frontend/src/utils/queueEta.ts b/frontend/src/utils/queueEta.ts deleted file mode 100644 index 5282af121..000000000 --- a/frontend/src/utils/queueEta.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { formatETA, type TimeFormat } from './date'; - -/** - * Formats the estimated completion time for a queue item if it were - * started at the current time. - * - * This is deliberately a per-job estimate rather than a cumulative - * queue forecast. - */ -export function formatQueueItemETA( - printTimeSeconds: number | null | undefined, - timeFormat: TimeFormat = 'system', - t?: Parameters[2], -): string | null { - if (printTimeSeconds == null || printTimeSeconds <= 0) return null; - - return formatETA(printTimeSeconds / 60, timeFormat, t); -}