diff --git a/frontend/src/components/PrintModal/PlateSelector.tsx b/frontend/src/components/PrintModal/PlateSelector.tsx index a586d27be..1cf8dc4dd 100644 --- a/frontend/src/components/PrintModal/PlateSelector.tsx +++ b/frontend/src/components/PrintModal/PlateSelector.tsx @@ -1,6 +1,6 @@ import { Layers, Check, AlertTriangle } from 'lucide-react'; -import { formatTime } from '../../utils/amsHelpers'; import type { PlateSelectorProps } from './types'; +import { formatDuration } from '../../utils/date'; /** * Plate selection grid for multi-plate 3MF files. @@ -61,7 +61,7 @@ export function PlateSelector({ ? plate.objects.slice(0, 3).join(', ') + (plate.objects.length > 3 ? '...' : '') : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`} - {plate.print_time_seconds != null ? ` • ${formatTime(plate.print_time_seconds)}` : ''} + {plate.print_time_seconds != null ? ` • ${formatDuration(plate.print_time_seconds)}` : ''}

{selectedPlate === plate.index && ( diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 37ab9ca3e..65eb0bcba 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -86,6 +86,7 @@ export default { unknown: 'Unbekannt', unknownError: 'Unbekannter Fehler', today: 'Heute', + tomorrow: 'Morgen', asap: 'Sofort', overdue: 'Überfällig', now: 'Jetzt', @@ -1875,7 +1876,6 @@ export default { cameraStream: 'Kamera-Stream', progress: 'Fortschritt', eta: 'ETA', - tomorrow: 'Morgen', printerIdle: 'Drucker ist inaktiv', printerOffline: 'Drucker offline', status: { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 258ef82d0..4e63809c4 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -86,6 +86,7 @@ export default { unknown: 'Unknown', unknownError: 'Unknown error', today: 'Today', + tomorrow: 'Tomorrow', asap: 'ASAP', overdue: 'Overdue', now: 'Now', @@ -1875,7 +1876,6 @@ export default { cameraStream: 'Camera stream', progress: 'Progress', eta: 'ETA', - tomorrow: 'Tomorrow', printerIdle: 'Printer is idle', printerOffline: 'Printer offline', status: { diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 66930631b..797f3cb52 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -86,6 +86,7 @@ export default { unknown: 'Inconnu', unknownError: 'Erreur inconnue', today: 'Aujourd\'hui', + tomorrow: 'Demain', asap: 'Dès que possible', overdue: 'En retard', now: 'Maintenant', @@ -1871,7 +1872,6 @@ export default { cameraStream: 'Flux caméra', progress: 'Progression', eta: 'Fin estimée', - tomorrow: 'Demain', printerIdle: 'Imprimante inactive', printerOffline: 'Imprimante hors ligne', status: { diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 2c85d2044..60b339330 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -83,6 +83,7 @@ export default { unknown: 'Sconosciuto', unknownError: 'Errore sconosciuto', today: 'Oggi', + tomorrow: 'Domani', asap: 'ASAP', overdue: 'Scaduto', now: 'Ora', @@ -1688,7 +1689,6 @@ export default { cameraStream: 'Stream camera', progress: 'Avanzamento', eta: 'ETA', - tomorrow: 'Domani', printerIdle: 'Stampante inattiva', printerOffline: 'Stampante offline', status: { diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 58baebcf7..ddd4be404 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -74,6 +74,7 @@ export default { unknown: '不明', unknownError: '不明なエラー', today: '今日', + tomorrow: '明日', asap: '即時', now: '今すぐ', collapse: '折りたたむ', @@ -1856,7 +1857,6 @@ export default { }, title: 'ストリームオーバーレイ', progress: '進捗', - tomorrow: '明日', printerIdle: 'プリンター待機中', printerOffline: 'プリンターオフライン', }, diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index de00a1e38..012f4526d 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -50,7 +50,7 @@ import { } from 'lucide-react'; import { api } from '../api/client'; import { openInSlicer, type SlicerType } from '../utils/slicer'; -import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat } from '../utils/date'; +import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date'; import { useIsMobile } from '../hooks/useIsMobile'; import type { Archive, ProjectListItem } from '../api/client'; import { Card, CardContent } from '../components/Card'; @@ -82,13 +82,6 @@ function formatFileSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -function formatDuration(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; -} - /** * Check if an archive filename represents a sliced/printable file. * Matches: .gcode, .gcode.3mf, .gcode.anything diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index 2a6bb76eb..bfa833daa 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -57,6 +57,7 @@ import { ModelViewerModal } from '../components/ModelViewerModal'; import { useToast } from '../contexts/ToastContext'; import { useIsMobile } from '../hooks/useIsMobile'; import { useAuth } from '../contexts/AuthContext'; +import { formatDuration } from '../utils/date'; type SortField = 'name' | 'date' | 'size' | 'type' | 'prints'; type SortDirection = 'asc' | 'desc'; @@ -70,15 +71,6 @@ function formatFileSize(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } -// Utility to format duration -function formatDuration(seconds: number | null): string { - if (!seconds) return '-'; - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - if (hours > 0) return `${hours}h ${mins}m`; - return `${mins}m`; -} - // New Folder Modal interface NewFolderModalProps { parentId: number | null; diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index d775db965..437b55b0a 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -46,7 +46,7 @@ import { import { useNavigate } from 'react-router-dom'; import { api, discoveryApi, firmwareApi } from '../api/client'; -import { formatDateOnly } from '../utils/date'; +import { formatDateOnly, formatETA, formatDuration } from '../utils/date'; import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; @@ -1087,42 +1087,6 @@ function getSpoolmanFillLevel( )); } -function formatTime(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; -} - -function formatETA(remainingMinutes: number, timeFormat: 'system' | '12h' | '24h' = 'system'): string { - const now = new Date(); - const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000); - const today = new Date(); - today.setHours(0, 0, 0, 0); - const etaDay = new Date(eta); - etaDay.setHours(0, 0, 0, 0); - - // Build time format options based on setting - const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' }; - if (timeFormat === '12h') { - timeOptions.hour12 = true; - } else if (timeFormat === '24h') { - timeOptions.hour12 = false; - } - // 'system' leaves hour12 undefined, letting the browser decide - - const timeStr = eta.toLocaleTimeString([], timeOptions); - - // Check if it's tomorrow or later - const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); - if (dayDiff === 0) { - return timeStr; - } else if (dayDiff === 1) { - return `Tomorrow ${timeStr}`; - } else { - return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr; - } -} - function getPrinterImage(model: string | null | undefined): string { if (!model) return '/img/printers/default.png'; @@ -1348,7 +1312,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) { /> {Math.round(nextFinish.progress)}% - ({formatTime(nextFinish.remainingMin * 60)}) + ({formatDuration(nextFinish.remainingMin * 60)}) @@ -2455,10 +2419,10 @@ function PrinterCard({ <> - {formatTime(status.remaining_time * 60)} + {formatDuration(status.remaining_time * 60)} - ETA {formatETA(status.remaining_time, timeFormat)} + ETA {formatETA(status.remaining_time, timeFormat, t)} )} diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index bbba6ce79..91123c437 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -50,7 +50,7 @@ import { Weight, } from 'lucide-react'; import { api } from '../api/client'; -import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date'; +import { parseUTCDate, formatDateTime, type TimeFormat, formatETA, formatDuration } from '../utils/date'; import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client'; import { Card, CardContent } from '../components/Card'; import { Button } from '../components/Button'; @@ -59,14 +59,6 @@ import { PrintModal } from '../components/PrintModal'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; -function formatDuration(seconds: number | null | undefined): string { - if (!seconds) return '--'; - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; -} - function formatWeight(g: number, useKg = false): string { if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`; return `${Math.round(g)}g`; @@ -322,6 +314,12 @@ function SortableQueueItem({ printerState?: string | null; t: (key: string, options?: Record) => string; }) { + const { data: status } = useQuery({ + queryKey: ['printerStatus', item.printer_id], + queryFn: () => api.getPrinterStatus(item.printer_id!), + refetchInterval: 30000, + enabled: item.printer_id != null && printerState === 'printing', + }); const canReorder = hasPermission('queue:reorder'); const { attributes, @@ -498,12 +496,36 @@ function SortableQueueItem({ {/* Progress bar for printing items - TODO: integrate with WebSocket */} - {isPrinting && ( + {isPrinting && status && (
-
-
+
+
+
+
+ {Math.round(status.progress || 0)}% +
+
+ {status.remaining_time != null && status.remaining_time > 0 && ( + <> + + + {formatDuration(status.remaining_time * 60)} + + + ETA {formatETA(status.remaining_time, timeFormat, t)} + + + )} + {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && ( + + + {status.layer_num}/{status.total_layers} + + )}
-

{t('queue.printingInProgress')}

)} diff --git a/frontend/src/pages/StreamOverlayPage.tsx b/frontend/src/pages/StreamOverlayPage.tsx index aed6f53c9..cd1e06fb8 100644 --- a/frontend/src/pages/StreamOverlayPage.tsx +++ b/frontend/src/pages/StreamOverlayPage.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { Layers, Clock, Timer, Printer } from 'lucide-react'; import { api } from '../api/client'; import type { PrinterStatus } from '../api/client'; +import { formatDuration, formatETA, type TimeFormat } from '../utils/date'; type TFunction = (key: string, options?: Record) => string; @@ -46,31 +47,6 @@ function parseConfig(params: URLSearchParams): OverlayConfig { }; } -function formatTime(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; -} - -function formatETA(remainingMinutes: number, t: TFunction): string { - const now = new Date(); - const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000); - const today = new Date(); - today.setHours(0, 0, 0, 0); - const etaDay = new Date(eta); - etaDay.setHours(0, 0, 0, 0); - - const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - - if (etaDay.getTime() === today.getTime()) { - return timeStr; - } else if (etaDay.getTime() === today.getTime() + 86400000) { - return `${t('streamOverlay.tomorrow')} ${timeStr}`; - } else { - return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr; - } -} - function getStatusText(status: PrinterStatus, t: TFunction): string { if (status.stg_cur_name) return status.stg_cur_name; @@ -146,6 +122,14 @@ export function StreamOverlayPage() { refetchInterval: 2000, }); + // Fetch settings info + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: api.getSettings, + }); + + const timeFormat: TimeFormat = settings?.time_format || 'system'; + // WebSocket for real-time updates useEffect(() => { if (!id) return; @@ -298,14 +282,14 @@ export function StreamOverlayPage() {
- {formatTime(status.remaining_time * 60)} + {formatDuration(status.remaining_time * 60)}
- {t('streamOverlay.eta')} {formatETA(status.remaining_time, t)} + {t('streamOverlay.eta')} {formatETA(status.remaining_time, timeFormat, t)}
diff --git a/frontend/src/utils/amsHelpers.ts b/frontend/src/utils/amsHelpers.ts index e1af13e06..3180da48d 100644 --- a/frontend/src/utils/amsHelpers.ts +++ b/frontend/src/utils/amsHelpers.ts @@ -94,17 +94,6 @@ export function getGlobalTrayId( return amsId * 4 + trayId; } -/** - * Format seconds to human readable time string. - */ -export function formatTime(seconds: number | null | undefined): string { - if (!seconds) return ''; - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; -} - /** * Get minimum datetime for scheduling (now + 1 minute). * Returns ISO string format for datetime-local input. diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index 1d53de926..14bcd4baf 100644 --- a/frontend/src/utils/date.ts +++ b/frontend/src/utils/date.ts @@ -376,3 +376,51 @@ export function formatTimeOnly( const finalOptions = applyTimeFormat({ ...defaultOptions, ...options }, timeFormat); return date.toLocaleTimeString([], finalOptions); } + +/** + * Calculate and format an ETA based on remaining minutes from now. + * + * @param remainingMinutes - Minutes until completion + * @param timeFormat - Time format setting ('system', '12h', '24h') + * @param t - Optional i18n translation function + * @returns Formatted ETA string (e.g., "3:45 PM", "Tomorrow 9:30 AM", "Wed 2:00 PM") + */ +export function formatETA( + remainingMinutes: number, + timeFormat: 'system' | '12h' | '24h' = 'system', + t?: (key: string) => string +): string { + const now = new Date(); + const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const etaDay = new Date(eta); + etaDay.setHours(0, 0, 0, 0); + + const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' }; + if (timeFormat === '12h') timeOptions.hour12 = true; + else if (timeFormat === '24h') timeOptions.hour12 = false; + + const timeStr = eta.toLocaleTimeString([], timeOptions); + const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / 86400000); + + if (dayDiff === 0) return timeStr; + if (dayDiff === 1) return `${t?.('common.tomorrow') ?? 'Tomorrow'} ${timeStr}`; + return `${eta.toLocaleDateString([], { weekday: 'short' })} ${timeStr}`; +} + +/** + * Format a duration in seconds to a human-readable string, with null handling. + * + * @param seconds - Duration in seconds, or null/undefined + * @returns Formatted string (e.g., "2h 30m", "45m") or "--" if no value + */ +export function formatDuration(seconds: number | null | undefined): string { + if (seconds == null || seconds < 0) return '--'; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +}