Browse Source

Refactors date and file formatting utilities

refactor(utils): consolidate date and file size formatting

Extracts formatting logic into dedicated utility functions to improve
code maintainability and consistency across components.

Changes:
- Consolidate date formatting functions (formatRelativeTime, etc.)
- Consolidate file size formatting into single formatFileSize utility
- Fix file size calculation errors
- Update UI components to use new utilities
- Change "ASAP" to "Waiting" for unscheduled queue items

Tests:
- Add comprehensive unit tests for date utilities
- Add unit tests for file size formatting
AneoPsy 3 months ago
parent
commit
b477fbf46d

+ 3 - 3
frontend/src/__tests__/components/FileManagerModal.test.tsx

@@ -28,7 +28,7 @@ const mockFiles = [
   {
     name: 'benchy.3mf',
     path: '/benchy.3mf',
-    size: 1024000,
+    size: 1048575,
     is_directory: false,
     mtime: '2024-01-15T10:00:00Z',
   },
@@ -135,8 +135,8 @@ describe('FileManagerModal', () => {
       );
 
       await waitFor(() => {
-        // 1024000 bytes = 1000 KB = ~1.0 MB
-        expect(screen.getByText('1000 KB')).toBeInTheDocument();
+        // 1024000 bytes = 1024.0 KB
+        expect(screen.getByText('1024.0 KB')).toBeInTheDocument();
       });
     });
   });

+ 2 - 2
frontend/src/__tests__/components/PrinterQueueWidget.test.tsx

@@ -77,11 +77,11 @@ describe('PrinterQueueWidget', () => {
       });
     });
 
-    it('shows ASAP for unscheduled items', async () => {
+    it('shows Waiting for unscheduled items', async () => {
       render(<PrinterQueueWidget printerId={1} />);
 
       await waitFor(() => {
-        expect(screen.getByText('ASAP')).toBeInTheDocument();
+        expect(screen.getByText('Waiting')).toBeInTheDocument();
       });
     });
   });

+ 437 - 0
frontend/src/__tests__/utils/date.test.ts

@@ -0,0 +1,437 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+  getDatePlaceholder,
+  getTimePlaceholder,
+  formatDateInput,
+  formatTimeInput,
+  parseDateInput,
+  parseTimeInput,
+  toDateTimeLocalValue,
+  applyTimeFormat,
+  parseUTCDate,
+  formatDate,
+  formatDateOnly,
+  formatDateTime,
+  formatTimeOnly,
+  formatETA,
+  formatDuration,
+  formatRelativeTime,
+} from '../../utils/date';
+
+describe('getDatePlaceholder', () => {
+  it('returns MM/DD/YYYY for us format', () => {
+    expect(getDatePlaceholder('us')).toBe('MM/DD/YYYY');
+  });
+
+  it('returns DD/MM/YYYY for eu format', () => {
+    expect(getDatePlaceholder('eu')).toBe('DD/MM/YYYY');
+  });
+
+  it('returns YYYY-MM-DD for iso format', () => {
+    expect(getDatePlaceholder('iso')).toBe('YYYY-MM-DD');
+  });
+
+  it('returns a placeholder for system format', () => {
+    const result = getDatePlaceholder('system');
+    expect(['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD']).toContain(result);
+  });
+});
+
+describe('getTimePlaceholder', () => {
+  it('returns HH:MM AM/PM for 12h format', () => {
+    expect(getTimePlaceholder('12h')).toBe('HH:MM AM/PM');
+  });
+
+  it('returns HH:MM for 24h format', () => {
+    expect(getTimePlaceholder('24h')).toBe('HH:MM');
+  });
+
+  it('returns a placeholder for system format', () => {
+    const result = getTimePlaceholder('system');
+    expect(['HH:MM AM/PM', 'HH:MM']).toContain(result);
+  });
+});
+
+describe('formatDateInput', () => {
+  const date = new Date(2025, 5, 15); // June 15, 2025
+
+  it('formats as MM/DD/YYYY for us format', () => {
+    expect(formatDateInput(date, 'us')).toBe('06/15/2025');
+  });
+
+  it('formats as DD/MM/YYYY for eu format', () => {
+    expect(formatDateInput(date, 'eu')).toBe('15/06/2025');
+  });
+
+  it('formats as YYYY-MM-DD for iso format', () => {
+    expect(formatDateInput(date, 'iso')).toBe('2025-06-15');
+  });
+
+  it('uses toLocaleDateString for system format', () => {
+    const result = formatDateInput(date, 'system');
+    expect(result).toBeTruthy();
+  });
+});
+
+describe('formatTimeInput', () => {
+  it('formats as 12h with AM', () => {
+    const date = new Date(2025, 0, 1, 9, 30);
+    expect(formatTimeInput(date, '12h')).toBe('9:30 AM');
+  });
+
+  it('formats as 12h with PM', () => {
+    const date = new Date(2025, 0, 1, 14, 45);
+    expect(formatTimeInput(date, '12h')).toBe('2:45 PM');
+  });
+
+  it('formats 12:00 as 12:00 PM', () => {
+    const date = new Date(2025, 0, 1, 12, 0);
+    expect(formatTimeInput(date, '12h')).toBe('12:00 PM');
+  });
+
+  it('formats 00:00 as 12:00 AM', () => {
+    const date = new Date(2025, 0, 1, 0, 0);
+    expect(formatTimeInput(date, '12h')).toBe('12:00 AM');
+  });
+
+  it('formats as 24h', () => {
+    const date = new Date(2025, 0, 1, 14, 30);
+    expect(formatTimeInput(date, '24h')).toBe('14:30');
+  });
+
+  it('pads hours in 24h format', () => {
+    const date = new Date(2025, 0, 1, 9, 5);
+    expect(formatTimeInput(date, '24h')).toBe('09:05');
+  });
+});
+
+describe('parseDateInput', () => {
+  it('parses us format MM/DD/YYYY', () => {
+    const result = parseDateInput('06/15/2025', 'us');
+    expect(result?.getFullYear()).toBe(2025);
+    expect(result?.getMonth()).toBe(5); // June
+    expect(result?.getDate()).toBe(15);
+  });
+
+  it('parses eu format DD/MM/YYYY', () => {
+    const result = parseDateInput('15/06/2025', 'eu');
+    expect(result?.getFullYear()).toBe(2025);
+    expect(result?.getMonth()).toBe(5);
+    expect(result?.getDate()).toBe(15);
+  });
+
+  it('parses iso format YYYY-MM-DD', () => {
+    const result = parseDateInput('2025-06-15', 'iso');
+    expect(result?.getFullYear()).toBe(2025);
+    expect(result?.getMonth()).toBe(5);
+    expect(result?.getDate()).toBe(15);
+  });
+
+  it('accepts different separators', () => {
+    expect(parseDateInput('06-15-2025', 'us')?.getDate()).toBe(15);
+    expect(parseDateInput('15.06.2025', 'eu')?.getDate()).toBe(15);
+    expect(parseDateInput('2025/06/15', 'iso')?.getDate()).toBe(15);
+  });
+
+  it('returns null for invalid input', () => {
+    expect(parseDateInput('', 'us')).toBeNull();
+    expect(parseDateInput('invalid', 'us')).toBeNull();
+    expect(parseDateInput('13/32/2025', 'us')).toBeNull(); // invalid month
+    expect(parseDateInput('01/01/1800', 'us')).toBeNull(); // year out of range
+  });
+
+  it('returns null for invalid month', () => {
+    expect(parseDateInput('13/01/2025', 'us')).toBeNull();
+    expect(parseDateInput('00/01/2025', 'us')).toBeNull();
+  });
+
+  it('returns null for invalid day', () => {
+    expect(parseDateInput('01/32/2025', 'us')).toBeNull();
+    expect(parseDateInput('01/00/2025', 'us')).toBeNull();
+  });
+});
+
+describe('parseTimeInput', () => {
+  it('parses 24h format', () => {
+    expect(parseTimeInput('14:30')).toEqual({ hours: 14, minutes: 30 });
+    expect(parseTimeInput('09:05')).toEqual({ hours: 9, minutes: 5 });
+    expect(parseTimeInput('0:00')).toEqual({ hours: 0, minutes: 0 });
+  });
+
+  it('parses 12h format with AM', () => {
+    expect(parseTimeInput('9:30 AM')).toEqual({ hours: 9, minutes: 30 });
+    expect(parseTimeInput('12:00 AM')).toEqual({ hours: 0, minutes: 0 });
+  });
+
+  it('parses 12h format with PM', () => {
+    expect(parseTimeInput('2:45 PM')).toEqual({ hours: 14, minutes: 45 });
+    expect(parseTimeInput('12:00 PM')).toEqual({ hours: 12, minutes: 0 });
+  });
+
+  it('is case insensitive for AM/PM', () => {
+    expect(parseTimeInput('9:30 am')).toEqual({ hours: 9, minutes: 30 });
+    expect(parseTimeInput('2:45 pm')).toEqual({ hours: 14, minutes: 45 });
+  });
+
+  it('returns null for invalid input', () => {
+    expect(parseTimeInput('')).toBeNull();
+    expect(parseTimeInput('invalid')).toBeNull();
+    expect(parseTimeInput('25:00')).toBeNull();
+    expect(parseTimeInput('12:60')).toBeNull();
+    expect(parseTimeInput('-1:00')).toBeNull();
+  });
+});
+
+describe('toDateTimeLocalValue', () => {
+  it('formats date to datetime-local value', () => {
+    const date = new Date(2025, 5, 15, 14, 30);
+    expect(toDateTimeLocalValue(date)).toBe('2025-06-15T14:30');
+  });
+
+  it('pads single digit values', () => {
+    const date = new Date(2025, 0, 5, 9, 5);
+    expect(toDateTimeLocalValue(date)).toBe('2025-01-05T09:05');
+  });
+});
+
+describe('applyTimeFormat', () => {
+  it('sets hour12 true for 12h format', () => {
+    const options: Intl.DateTimeFormatOptions = {};
+    applyTimeFormat(options, '12h');
+    expect(options.hour12).toBe(true);
+  });
+
+  it('sets hour12 false for 24h format', () => {
+    const options: Intl.DateTimeFormatOptions = {};
+    applyTimeFormat(options, '24h');
+    expect(options.hour12).toBe(false);
+  });
+
+  it('leaves hour12 undefined for system format', () => {
+    const options: Intl.DateTimeFormatOptions = {};
+    applyTimeFormat(options, 'system');
+    expect(options.hour12).toBeUndefined();
+  });
+
+  it('returns the modified options object', () => {
+    const options: Intl.DateTimeFormatOptions = { hour: '2-digit' };
+    const result = applyTimeFormat(options, '12h');
+    expect(result).toBe(options);
+    expect(result.hour).toBe('2-digit');
+  });
+});
+
+describe('parseUTCDate', () => {
+  it('returns null for null/undefined input', () => {
+    expect(parseUTCDate(null)).toBeNull();
+    expect(parseUTCDate(undefined)).toBeNull();
+    expect(parseUTCDate('')).toBeNull();
+  });
+
+  it('parses ISO string with Z suffix as-is', () => {
+    const result = parseUTCDate('2025-06-15T12:00:00Z');
+    expect(result).toBeInstanceOf(Date);
+    expect(result?.getUTCHours()).toBe(12);
+  });
+
+  it('parses ISO string with timezone offset as-is', () => {
+    const result = parseUTCDate('2025-06-15T12:00:00+05:00');
+    expect(result).toBeInstanceOf(Date);
+  });
+
+  it('appends Z to strings without timezone indicator', () => {
+    const result = parseUTCDate('2025-06-15T12:00:00');
+    expect(result).toBeInstanceOf(Date);
+    expect(result?.getUTCHours()).toBe(12);
+  });
+});
+
+describe('formatDate', () => {
+  it('returns empty string for null input', () => {
+    expect(formatDate(null)).toBe('');
+    expect(formatDate(undefined)).toBe('');
+  });
+
+  it('formats a valid date string', () => {
+    const result = formatDate('2025-06-15T12:00:00Z');
+    expect(result).toBeTruthy();
+    expect(result).toContain('2025');
+  });
+
+  it('accepts custom options', () => {
+    const result = formatDate('2025-06-15T12:00:00Z', { year: 'numeric' });
+    expect(result).toContain('2025');
+  });
+});
+
+describe('formatDateOnly', () => {
+  it('returns empty string for null input', () => {
+    expect(formatDateOnly(null)).toBe('');
+  });
+
+  it('formats date without time', () => {
+    const result = formatDateOnly('2025-06-15T12:00:00Z');
+    expect(result).toBeTruthy();
+    expect(result).toContain('2025');
+  });
+});
+
+describe('formatDateTime', () => {
+  it('returns empty string for null input', () => {
+    expect(formatDateTime(null)).toBe('');
+  });
+
+  it('formats with 12h time format', () => {
+    const result = formatDateTime('2025-06-15T14:00:00Z', '12h');
+    expect(result).toBeTruthy();
+  });
+
+  it('formats with 24h time format', () => {
+    const result = formatDateTime('2025-06-15T14:00:00Z', '24h');
+    expect(result).toBeTruthy();
+  });
+});
+
+describe('formatTimeOnly', () => {
+  it('formats time with 12h format', () => {
+    const date = new Date(2025, 5, 15, 14, 30);
+    const result = formatTimeOnly(date, '12h');
+    expect(result).toMatch(/2:30|02:30/);
+    expect(result.toUpperCase()).toContain('PM');
+  });
+
+  it('formats time with 24h format', () => {
+    const date = new Date(2025, 5, 15, 14, 30);
+    const result = formatTimeOnly(date, '24h');
+    expect(result).toContain('14:30');
+  });
+});
+
+describe('formatETA', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('returns time only for same day', () => {
+    const result = formatETA(60); // 1 hour from now
+    expect(result).toBeTruthy();
+  });
+
+  it('includes "Tomorrow" for next day', () => {
+    const result = formatETA(60 * 24); // 24 hours from now
+    expect(result).toContain('Tomorrow');
+  });
+
+  it('uses translation function for tomorrow', () => {
+    const t = vi.fn((key: string) => (key === 'common.tomorrow' ? 'Demain' : key));
+    const result = formatETA(60 * 24, 'system', t);
+    expect(result).toContain('Demain');
+  });
+
+  it('shows weekday for dates beyond tomorrow', () => {
+    const result = formatETA(60 * 48); // 48 hours from now
+    expect(result).not.toContain('Tomorrow');
+  });
+});
+
+describe('formatDuration', () => {
+  it('returns "--" for null/undefined', () => {
+    expect(formatDuration(null)).toBe('--');
+    expect(formatDuration(undefined)).toBe('--');
+  });
+
+  it('returns "--" for negative values', () => {
+    expect(formatDuration(-1)).toBe('--');
+  });
+
+  it('formats minutes only when under 1 hour', () => {
+    expect(formatDuration(0)).toBe('0m');
+    expect(formatDuration(60)).toBe('1m');
+    expect(formatDuration(2700)).toBe('45m');
+  });
+
+  it('formats hours and minutes', () => {
+    expect(formatDuration(3600)).toBe('1h 0m');
+    expect(formatDuration(5400)).toBe('1h 30m');
+    expect(formatDuration(9000)).toBe('2h 30m');
+  });
+});
+
+describe('formatRelativeTime', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('returns "-" for null input', () => {
+    expect(formatRelativeTime(null)).toBe('-');
+  });
+
+  it('returns translated unknown for null with translation', () => {
+    const t = vi.fn((key: string) => (key === 'time.unknown' ? 'Unknown' : key));
+    expect(formatRelativeTime(null, 'system', t)).toBe('Unknown');
+  });
+
+  it('returns "Just now" for times less than 1 minute ago', () => {
+    expect(formatRelativeTime('2025-06-15T11:59:30Z')).toBe('Just now');
+  });
+
+  it('returns "Now" for times less than 1 minute in future', () => {
+    expect(formatRelativeTime('2025-06-15T12:00:30Z')).toBe('Now');
+  });
+
+  it('returns minutes ago for times under 1 hour ago', () => {
+    expect(formatRelativeTime('2025-06-15T11:55:00Z')).toBe('5m ago');
+    expect(formatRelativeTime('2025-06-15T11:30:00Z')).toBe('30m ago');
+  });
+
+  it('returns "in Xm" for times under 1 hour in future', () => {
+    expect(formatRelativeTime('2025-06-15T12:05:00Z')).toBe('in 5m');
+    expect(formatRelativeTime('2025-06-15T12:30:00Z')).toBe('in 30m');
+  });
+
+  it('returns hours ago for times under 1 day ago', () => {
+    expect(formatRelativeTime('2025-06-15T10:00:00Z')).toBe('2h ago');
+    expect(formatRelativeTime('2025-06-15T06:00:00Z')).toBe('6h ago');
+  });
+
+  it('returns "in Xh" for times under 1 day in future', () => {
+    expect(formatRelativeTime('2025-06-15T14:00:00Z')).toBe('in 2h');
+    expect(formatRelativeTime('2025-06-15T18:00:00Z')).toBe('in 6h');
+  });
+
+  it('returns days ago for times under 7 days ago', () => {
+    expect(formatRelativeTime('2025-06-14T12:00:00Z')).toBe('1d ago');
+    expect(formatRelativeTime('2025-06-10T12:00:00Z')).toBe('5d ago');
+  });
+
+  it('returns "in Xd" for times under 7 days in future', () => {
+    expect(formatRelativeTime('2025-06-16T12:00:00Z')).toBe('in 1d');
+    expect(formatRelativeTime('2025-06-20T12:00:00Z')).toBe('in 5d');
+  });
+
+  it('returns formatted date for times older than 7 days', () => {
+    const result = formatRelativeTime('2025-06-01T12:00:00Z');
+    expect(result).toContain('2025');
+  });
+
+  it('uses translation function when provided', () => {
+    const t = vi.fn((key: string, options?: Record<string, unknown>) => {
+      if (key === 'time.minsAgo') return `${options?.count} minutes ago`;
+      if (key === 'time.inMins') return `in ${options?.count} minutes`;
+      return key;
+    });
+
+    expect(formatRelativeTime('2025-06-15T11:55:00Z', 'system', t)).toBe('5 minutes ago');
+    expect(formatRelativeTime('2025-06-15T12:05:00Z', 'system', t)).toBe('in 5 minutes');
+  });
+});

+ 43 - 0
frontend/src/__tests__/utils/file.test.ts

@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { formatFileSize } from '../../utils/file';
+
+describe('formatFileSize', () => {
+  it('returns "0 B" for 0 bytes', () => {
+    expect(formatFileSize(0)).toBe('0 B');
+  });
+
+  it('returns bytes without decimals for values under 1 KB', () => {
+    expect(formatFileSize(1)).toBe('1 B');
+    expect(formatFileSize(500)).toBe('500 B');
+    expect(formatFileSize(1023)).toBe('1023 B');
+  });
+
+  it('returns KB with 1 decimal for values under 1 MB', () => {
+    expect(formatFileSize(1024)).toBe('1.0 KB');
+    expect(formatFileSize(1536)).toBe('1.5 KB');
+    expect(formatFileSize(10240)).toBe('10.0 KB');
+  });
+
+  it('returns MB with 1 decimal for values under 1 GB', () => {
+    expect(formatFileSize(1048576)).toBe('1.0 MB');
+    expect(formatFileSize(1572864)).toBe('1.5 MB');
+    expect(formatFileSize(10485760)).toBe('10.0 MB');
+  });
+
+  it('returns GB with 1 decimal for values under 1 TB', () => {
+    expect(formatFileSize(1073741824)).toBe('1.0 GB');
+    expect(formatFileSize(1610612736)).toBe('1.5 GB');
+  });
+
+  it('returns TB with 1 decimal for very large values', () => {
+    expect(formatFileSize(1099511627776)).toBe('1.0 TB');
+    expect(formatFileSize(1649267441664)).toBe('1.5 TB');
+  });
+
+  it('handles edge cases at unit boundaries', () => {
+    expect(formatFileSize(1023)).toBe('1023 B');
+    expect(formatFileSize(1024)).toBe('1.0 KB');
+    expect(formatFileSize(1048575)).toBe('1024.0 KB');
+    expect(formatFileSize(1048576)).toBe('1.0 MB');
+  });
+});

+ 3 - 20
frontend/src/components/FileManagerModal.tsx

@@ -29,6 +29,7 @@ import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import type { PlateMetadata } from '../types/plates';
 import { useToast } from '../contexts/ToastContext';
+import { formatFileSize } from '../utils/file';
 
 interface FileManagerModalProps {
   printerId: number;
@@ -235,24 +236,6 @@ function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: Prin
   );
 }
 
-function formatFileSize(bytes: number): string {
-  if (bytes === 0) return '0 B';
-  const k = 1024;
-  const sizes = ['B', 'KB', 'MB', 'GB'];
-  const i = Math.floor(Math.log(bytes) / Math.log(k));
-  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
-}
-
-function formatStorageSize(bytes: number): string {
-  if (bytes === 0) return '0 GB';
-  const gb = bytes / (1024 * 1024 * 1024);
-  if (gb >= 1) {
-    return `${gb.toFixed(1)} GB`;
-  }
-  const mb = bytes / (1024 * 1024);
-  return `${mb.toFixed(0)} MB`;
-}
-
 function getFileIcon(filename: string, isDirectory: boolean) {
   if (isDirectory) return Folder;
 
@@ -444,13 +427,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               {storageData && (storageData.used_bytes != null || storageData.free_bytes != null) && (
                 <div className="text-sm text-bambu-gray flex items-center gap-2">
                   {storageData.used_bytes != null && (
-                    <span>{t('printerFiles.storageUsed')} {formatStorageSize(storageData.used_bytes)}</span>
+                    <span>{t('printerFiles.storageUsed')} {formatFileSize(storageData.used_bytes)}</span>
                   )}
                   {storageData.used_bytes != null && storageData.free_bytes != null && (
                     <span className="text-bambu-dark-tertiary">|</span>
                   )}
                   {storageData.free_bytes != null && (
-                    <span>{t('printerFiles.storageFree')} {formatStorageSize(storageData.free_bytes)}</span>
+                    <span>{t('printerFiles.storageFree')} {formatFileSize(storageData.free_bytes)}</span>
                   )}
                 </div>
               )}

+ 3 - 25
frontend/src/components/GitHubBackupSettings.tsx

@@ -35,6 +35,7 @@ import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
+import { formatRelativeTime } from '../utils/date';
 
 interface StatusBadgeProps {
   status: string | null;
@@ -71,29 +72,6 @@ function formatDateTime(dateStr: string | null): string {
   return date.toLocaleString();
 }
 
-function formatRelativeTime(dateStr: string | null): string {
-  if (!dateStr) return '-';
-  const date = new Date(dateStr);
-  const now = new Date();
-  const diffMs = date.getTime() - now.getTime();
-  const diffMins = Math.round(diffMs / 60000);
-
-  if (diffMins < 0) {
-    const absMins = Math.abs(diffMins);
-    if (absMins < 60) return `${absMins}m ago`;
-    const hours = Math.floor(absMins / 60);
-    if (hours < 24) return `${hours}h ago`;
-    const days = Math.floor(hours / 24);
-    return `${days}d ago`;
-  } else {
-    if (diffMins < 60) return `in ${diffMins}m`;
-    const hours = Math.floor(diffMins / 60);
-    if (hours < 24) return `in ${hours}h`;
-    const days = Math.floor(hours / 24);
-    return `in ${days}d`;
-  }
-}
-
 export function GitHubBackupSettings() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
@@ -563,7 +541,7 @@ export function GitHubBackupSettings() {
                   <div className="flex items-center gap-2 text-bambu-gray">
                     {status.last_backup_at ? (
                       <>
-                        <span>Last backup: {formatRelativeTime(status.last_backup_at)}</span>
+                        <span>Last backup: {formatRelativeTime(status.last_backup_at, 'system', t)}</span>
                         <StatusBadge status={status.last_backup_status} />
                       </>
                     ) : (
@@ -573,7 +551,7 @@ export function GitHubBackupSettings() {
                   {status.next_scheduled_run && (
                     <span className="text-bambu-gray">
                       <Clock className="w-3 h-3 inline mr-1" />
-                      Next: {formatRelativeTime(status.next_scheduled_run)}
+                      Next: {formatRelativeTime(status.next_scheduled_run, 'system', t)}
                     </span>
                   )}
                 </div>

+ 1 - 6
frontend/src/components/PendingUploadsPanel.tsx

@@ -8,12 +8,7 @@ import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { ConfirmModal } from './ConfirmModal';
-
-function formatFileSize(bytes: number): string {
-  if (bytes < 1024) return `${bytes} B`;
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-}
+import { formatFileSize } from '../utils/file';
 
 function formatTimeAgo(dateStr: string): string {
   const date = new Date(dateStr);

+ 2 - 16
frontend/src/components/PrinterQueueWidget.tsx

@@ -5,27 +5,13 @@ import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
-import { parseUTCDate } from '../utils/date';
+import { formatRelativeTime } from '../utils/date';
 
 interface PrinterQueueWidgetProps {
   printerId: number;
   printerState?: string | null;
 }
 
-function formatRelativeTime(dateString: string | null): string {
-  if (!dateString) return 'ASAP';
-  const date = parseUTCDate(dateString);
-  if (!date) return 'ASAP';
-  const now = new Date();
-  const diff = date.getTime() - now.getTime();
-
-  if (diff < 0) return 'Now';
-  if (diff < 60000) return 'In <1 min';
-  if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
-  if (diff < 86400000) return `In ${Math.round(diff / 3600000)}h`;
-  return date.toLocaleDateString();
-}
-
 export function PrinterQueueWidget({ printerId, printerState }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -116,7 +102,7 @@ export function PrinterQueueWidget({ printerId, printerState }: PrinterQueueWidg
         <div className="flex items-center gap-2 flex-shrink-0">
           <span className="text-xs text-bambu-gray flex items-center gap-1">
             <Clock className="w-3 h-3" />
-            {formatRelativeTime(nextItem?.scheduled_time || null)}
+            {nextItem?.scheduled_time ? formatRelativeTime(nextItem.scheduled_time, 'system', t) : t('time.waiting')}
           </span>
           {totalPending > 1 && (
             <span className="text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded">

+ 14 - 0
frontend/src/i18n/locales/de.ts

@@ -3433,4 +3433,18 @@ export default {
 
   // Spoolman Settings
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting:'Wartend',
+    justNow: 'Gerade eben',
+    now: 'Jetzt',
+    minsAgo: 'vor {{count}}m',
+    inMins: 'in {{count}}m',
+    hoursAgo: 'vor {{count}}h',
+    inHours: 'in {{count}}h',
+    daysAgo: 'vor {{count}}d',
+    inDays: 'in {{count}}d',
+  },
 };

+ 14 - 0
frontend/src/i18n/locales/en.ts

@@ -3438,4 +3438,18 @@ export default {
 
   // Spoolman Settings
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting:'Waiting',
+    justNow: 'Just now',
+    now: 'Now',
+    minsAgo: '{{count}}m ago',
+    inMins: 'in {{count}}m',
+    hoursAgo: '{{count}}h ago',
+    inHours: 'in {{count}}h',
+    daysAgo: '{{count}}d ago',
+    inDays: '{{count}}d',
+  },
 };

+ 14 - 0
frontend/src/i18n/locales/fr.ts

@@ -3426,4 +3426,18 @@ export default {
 
   // Spoolman Settings
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting:'En attente',
+    justNow: 'À l\'instant',
+    now: 'Maintenant',
+    minsAgo: 'il y a {{count}}m',
+    inMins: 'dans {{count}}m',
+    hoursAgo: 'il y a {{count}}h',
+    inHours: 'dans {{count}}h',
+    daysAgo: 'il y a {{count}}j',
+    inDays: 'dans {{count}}j',
+  },
 };

+ 14 - 0
frontend/src/i18n/locales/it.ts

@@ -2814,4 +2814,18 @@ export default {
     configuring: 'Configurazione...',
     configureSlot: 'Configura slot',
   },
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting:'In attesa',
+    justNow: 'Adesso',
+    now: 'Proprio ora',
+    minsAgo: '{{count}}m fa',
+    inMins: 'tra {{count}}m',
+    hoursAgo: '{{count}}h fa',
+    inHours: 'tra {{count}}h',
+    daysAgo: '{{count}}g fa',
+    inDays: 'tra {{count}}g',
+  },
 };

+ 14 - 0
frontend/src/i18n/locales/ja.ts

@@ -3269,4 +3269,18 @@ export default {
 
   // Spoolman Settings
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting:'待機中',
+    justNow: 'たった今',
+    now: '今すぐ',
+    minsAgo: '{{count}}分前',
+    inMins: 'あと{{count}}分',
+    hoursAgo: '{{count}}時間前',
+    inHours: 'あと{{count}}時間',
+    daysAgo: '{{count}}日前',
+    inDays: 'あと{{count}}日',
+  },
 };

+ 1 - 6
frontend/src/pages/ArchivesPage.tsx

@@ -74,15 +74,10 @@ import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { TagManagementModal } from '../components/TagManagementModal';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
+import { formatFileSize } from '../utils/file';
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
-function formatFileSize(bytes: number): string {
-  if (bytes < 1024) return `${bytes} B`;
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-}
-
 /**
  * Check if an archive filename represents a sliced/printable file.
  * Matches: .gcode, .gcode.3mf, .gcode.anything

+ 1 - 8
frontend/src/pages/FileManagerPage.tsx

@@ -58,19 +58,12 @@ import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration } from '../utils/date';
+import { formatFileSize } from '../utils/file';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
-// Utility to format file size
-function formatFileSize(bytes: number): string {
-  if (bytes < 1024) return `${bytes} B`;
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
-}
-
 // New Folder Modal
 interface NewFolderModalProps {
   parentId: number | null;

+ 2 - 19
frontend/src/pages/ProfilesPage.tsx

@@ -43,7 +43,7 @@ import {
   HardDrive,
 } from 'lucide-react';
 import { api } from '../api/client';
-import { parseUTCDate } from '../utils/date';
+import { formatRelativeTime, parseUTCDate } from '../utils/date';
 import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -90,23 +90,6 @@ function isUserPreset(settingId: string): boolean {
   return /^(P[FPM]US|PF\d|PP\d)/.test(settingId);
 }
 
-// Format relative time
-function formatRelativeTime(dateStr: string, t: TFunction): string {
-  const date = parseUTCDate(dateStr);
-  if (!date) return '';
-  const now = new Date();
-  const diffMs = now.getTime() - date.getTime();
-  const diffMins = Math.floor(diffMs / 60000);
-  const diffHours = Math.floor(diffMs / 3600000);
-  const diffDays = Math.floor(diffMs / 86400000);
-
-  if (diffMins < 1) return t('profiles.time.justNow');
-  if (diffMins < 60) return t('profiles.time.minsAgo', { count: diffMins });
-  if (diffHours < 24) return t('profiles.time.hoursAgo', { count: diffHours });
-  if (diffDays < 7) return t('profiles.time.daysAgo', { count: diffDays });
-  return date.toLocaleDateString();
-}
-
 // ============================================================================
 // LOGIN FORM
 // ============================================================================
@@ -2646,7 +2629,7 @@ function CloudProfilesView({
         {lastSyncTime && (
           <div className="flex items-center gap-1">
             <Clock className="w-3 h-3" />
-            {t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), t)}
+            {t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), 'system', t)}
           </div>
         )}
         <span>{t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })}</span>

+ 2 - 17
frontend/src/pages/QueuePage.tsx

@@ -50,7 +50,7 @@ import {
   Weight,
 } from 'lucide-react';
 import { api } from '../api/client';
-import { parseUTCDate, formatDateTime, type TimeFormat, formatETA, formatDuration } from '../utils/date';
+import { parseUTCDate, formatDateTime, type TimeFormat, formatETA, formatDuration, formatRelativeTime } from '../utils/date';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -64,21 +64,6 @@ function formatWeight(g: number, useKg = false): string {
   return `${Math.round(g)}g`;
 }
 
-function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
-  if (!dateString) return t?.('queue.time.asap') ?? 'ASAP';
-  const date = parseUTCDate(dateString);
-  if (!date) return t?.('queue.time.asap') ?? 'ASAP';
-  const now = new Date();
-  const diff = date.getTime() - now.getTime();
-
-  if (diff < -60000) return t?.('queue.time.overdue') ?? 'Overdue';
-  if (diff < 0) return t?.('queue.time.now') ?? 'Now';
-  if (diff < 60000) return t?.('queue.time.lessThanMinute') ?? 'In less than a minute';
-  if (diff < 3600000) return t?.('queue.time.inMinutes', { count: Math.round(diff / 60000) }) ?? `In ${Math.round(diff / 60000)} min`;
-  if (diff < 86400000) return t?.('queue.time.inHours', { count: Math.round(diff / 3600000) }) ?? `In ${Math.round(diff / 3600000)} hours`;
-  return formatDateTime(dateString, timeFormat);
-}
-
 function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
   // Special case: pending with waiting_reason shows as "Waiting"
   if (status === 'pending' && waitingReason) {
@@ -492,7 +477,7 @@ function SortableQueueItem({
             {isPending && !item.manual_start && (
               <span className="flex items-center gap-1.5">
                 <Clock className="w-3.5 h-3.5" />
-                {formatRelativeTime(item.scheduled_time, timeFormat, t)}
+                {item.scheduled_time ? formatRelativeTime(item.scheduled_time, timeFormat, t) : t?.('queue.time.asap') ?? 'ASAP'}
               </span>
             )}
           </div>

+ 61 - 0
frontend/src/utils/date.ts

@@ -424,3 +424,64 @@ export function formatDuration(seconds: number | null | undefined): string {
   
   return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
 }
+
+type TranslateFunction = (key: string, options?: Record<string, unknown>) => string;
+
+/**
+ * Format a date string as a human-readable relative time expression.
+ *
+ * @param dateStr - UTC date string, or null
+ * @param timeFormat - Time format preference ('12h', '24h', or 'system')
+ * @param t - Optional translation function for i18n support
+ * @returns Relative string (e.g., "5m ago", "in 2h", "3d ago") or formatted date if older than 7 days
+ */
+export function formatRelativeTime(
+  dateStr: string | null,
+  timeFormat: TimeFormat = 'system',
+  t?: TranslateFunction
+): string {
+  if (!dateStr) return t?.('time.unknown') ?? '-';
+
+  const date = parseUTCDate(dateStr);
+  if (!date) return t?.('time.unknown') ?? '-';
+
+  const now = new Date();
+  const diffMs = date.getTime() - now.getTime();
+  const isPast = diffMs < 0;
+  const absDiffMs = Math.abs(diffMs);
+
+  const minutes = Math.floor(absDiffMs / 60000);
+  const hours = Math.floor(absDiffMs / 3600000);
+  const days = Math.floor(absDiffMs / 86400000);
+
+  // Less than 1 minute
+  if (minutes < 1) {
+    return isPast
+      ? t?.('time.justNow') ?? 'Just now'
+      : t?.('time.now') ?? 'Now';
+  }
+
+  // Less than 1 hour
+  if (hours < 1) {
+    return isPast
+      ? t?.('time.minsAgo', { count: minutes }) ?? `${minutes}m ago`
+      : t?.('time.inMins', { count: minutes }) ?? `in ${minutes}m`;
+  }
+
+  // Less than 1 day
+  if (days < 1) {
+    return isPast
+      ? t?.('time.hoursAgo', { count: hours }) ?? `${hours}h ago`
+      : t?.('time.inHours', { count: hours }) ?? `in ${hours}h`;
+  }
+
+  // Less than 7 days
+  if (days < 7) {
+    return isPast
+      ? t?.('time.daysAgo', { count: days }) ?? `${days}d ago`
+      : t?.('time.inDays', { count: days }) ?? `in ${days}d`;
+  }
+
+  // Older than 7 days
+  return formatDateTime(dateStr, timeFormat);
+}

+ 21 - 0
frontend/src/utils/file.ts

@@ -0,0 +1,21 @@
+
+/**
+ * Formats a byte count into a human-readable string (e.g. `1.5 MB`).
+ *
+ * @param bytes - The number of bytes to format.
+ * @returns A formatted string with the appropriate unit (B, KB, MB, GB, or TB).
+ */
+export function formatFileSize(bytes: number): string {
+  if (bytes === 0) return '0 B';
+  
+  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+  const k = 1024;
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+  
+  const size = bytes / Math.pow(k, i);
+  
+  // No decimals for bytes, 1 decimal for larger units
+  return i === 0 
+    ? `${size} ${units[i]}` 
+    : `${size.toFixed(1)} ${units[i]}`;
+}