Browse Source

Merge pull request #395 from aneopsy/queue-currently-printing-card

feat(Queue) Add more information in Currently Printing job
MartinNYHC 3 months ago
parent
commit
82964da459

+ 2 - 2
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)}` : ''}
               </p>
             </div>
             {selectedPlate === plate.index && (

+ 1 - 1
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: {

+ 1 - 1
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: {

+ 1 - 1
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: {

+ 1 - 1
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: {

+ 1 - 1
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: 'プリンターオフライン',
   },

+ 1 - 8
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

+ 1 - 9
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;

+ 4 - 40
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 }) {
                 />
               </div>
               <span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
-              <span className="text-bambu-gray">({formatTime(nextFinish.remainingMin * 60)})</span>
+              <span className="text-bambu-gray">({formatDuration(nextFinish.remainingMin * 60)})</span>
             </div>
           </div>
         </>
@@ -2455,10 +2419,10 @@ function PrinterCard({
                               <>
                                 <span className="flex items-center gap-1">
                                   <Clock className="w-3 h-3" />
-                                  {formatTime(status.remaining_time * 60)}
+                                  {formatDuration(status.remaining_time * 60)}
                                 </span>
                                 <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
-                                  ETA {formatETA(status.remaining_time, timeFormat)}
+                                  ETA {formatETA(status.remaining_time, timeFormat, t)}
                                 </span>
                               </>
                             )}

+ 35 - 13
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, unknown>) => 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({
           </div>
 
           {/* Progress bar for printing items - TODO: integrate with WebSocket */}
-          {isPrinting && (
+          {isPrinting && status && (
             <div className="mt-3">
-              <div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
-                <div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
+              <div className="flex items-center justify-between text-sm">
+                <div className="flex-1 bg-bambu-dark-tertiary rounded-full h-2 mr-3">
+                  <div
+                    className="bg-bambu-green h-2 rounded-full transition-all"
+                    style={{ width: `${status.progress || 0}%` }}
+                  />
+                </div>
+                <span className="text-white">{Math.round(status.progress || 0)}%</span>
+              </div>
+              <div className="flex items-center gap-3 mt-2 text-xs text-bambu-gray">
+                {status.remaining_time != null && status.remaining_time > 0 && (
+                  <>
+                    <span className="flex items-center gap-1">
+                      <Clock className="w-3 h-3" />
+                      {formatDuration(status.remaining_time * 60)}
+                    </span>
+                    <span className="text-bambu-green font-medium" title={t('printers.estimatedCompletion')}>
+                      ETA {formatETA(status.remaining_time, timeFormat, t)}
+                    </span>
+                  </>
+                )}
+                {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
+                  <span className="flex items-center gap-1">
+                    <Layers className="w-3 h-3" />
+                    {status.layer_num}/{status.total_layers}
+                  </span>
+                )}
               </div>
-              <p className="text-xs text-bambu-gray mt-1">{t('queue.printingInProgress')}</p>
             </div>
           )}
 

+ 11 - 27
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, unknown>) => 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() {
                   <div className={`flex items-center ${sizes.gap} text-white/70`}>
                     <Timer className={sizes.icon} />
                     <span className={`${sizes.text} text-white`}>
-                      {formatTime(status.remaining_time * 60)}
+                      {formatDuration(status.remaining_time * 60)}
                     </span>
                   </div>
 
                   <div className={`flex items-center ${sizes.gap} text-white/70`}>
                     <Clock className={sizes.icon} />
                     <span className={`${sizes.text} text-white`}>
-                      {t('streamOverlay.eta')} {formatETA(status.remaining_time, t)}
+                      {t('streamOverlay.eta')} {formatETA(status.remaining_time, timeFormat, t)}
                     </span>
                   </div>
                 </>

+ 0 - 11
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.

+ 48 - 0
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`;
+}