diff --git a/frontend/src/__tests__/components/FileManagerModal.test.tsx b/frontend/src/__tests__/components/FileManagerModal.test.tsx index ab7a979a1..0f15e032f 100644 --- a/frontend/src/__tests__/components/FileManagerModal.test.tsx +++ b/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(); }); }); }); diff --git a/frontend/src/__tests__/components/PrinterQueueWidget.test.tsx b/frontend/src/__tests__/components/PrinterQueueWidget.test.tsx index 90c05e25b..a3e06cf7f 100644 --- a/frontend/src/__tests__/components/PrinterQueueWidget.test.tsx +++ b/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(); await waitFor(() => { - expect(screen.getByText('ASAP')).toBeInTheDocument(); + expect(screen.getByText('Waiting')).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/__tests__/utils/date.test.ts b/frontend/src/__tests__/utils/date.test.ts new file mode 100644 index 000000000..4934dd8b2 --- /dev/null +++ b/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) => { + 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'); + }); +}); \ No newline at end of file diff --git a/frontend/src/__tests__/utils/file.test.ts b/frontend/src/__tests__/utils/file.test.ts new file mode 100644 index 000000000..0d2fb7d8a --- /dev/null +++ b/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'); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/FileManagerModal.tsx b/frontend/src/components/FileManagerModal.tsx index de6802b3e..8955dbd09 100644 --- a/frontend/src/components/FileManagerModal.tsx +++ b/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) && (
{storageData.used_bytes != null && ( - {t('printerFiles.storageUsed')} {formatStorageSize(storageData.used_bytes)} + {t('printerFiles.storageUsed')} {formatFileSize(storageData.used_bytes)} )} {storageData.used_bytes != null && storageData.free_bytes != null && ( | )} {storageData.free_bytes != null && ( - {t('printerFiles.storageFree')} {formatStorageSize(storageData.free_bytes)} + {t('printerFiles.storageFree')} {formatFileSize(storageData.free_bytes)} )}
)} diff --git a/frontend/src/components/GitHubBackupSettings.tsx b/frontend/src/components/GitHubBackupSettings.tsx index 1b2d2626b..af7528cdb 100644 --- a/frontend/src/components/GitHubBackupSettings.tsx +++ b/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() {
{status.last_backup_at ? ( <> - Last backup: {formatRelativeTime(status.last_backup_at)} + Last backup: {formatRelativeTime(status.last_backup_at, 'system', t)} ) : ( @@ -573,7 +551,7 @@ export function GitHubBackupSettings() { {status.next_scheduled_run && ( - Next: {formatRelativeTime(status.next_scheduled_run)} + Next: {formatRelativeTime(status.next_scheduled_run, 'system', t)} )}
diff --git a/frontend/src/components/PendingUploadsPanel.tsx b/frontend/src/components/PendingUploadsPanel.tsx index 1feb54ea0..7280c351c 100644 --- a/frontend/src/components/PendingUploadsPanel.tsx +++ b/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); diff --git a/frontend/src/components/PrinterQueueWidget.tsx b/frontend/src/components/PrinterQueueWidget.tsx index 998d9b4d3..e582a7423 100644 --- a/frontend/src/components/PrinterQueueWidget.tsx +++ b/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
- {formatRelativeTime(nextItem?.scheduled_time || null)} + {nextItem?.scheduled_time ? formatRelativeTime(nextItem.scheduled_time, 'system', t) : t('time.waiting')} {totalPending > 1 && ( diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 2c4b87003..75ebb7d86 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/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', + }, }; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 36a4a9f43..b6d2842d2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/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', + }, }; diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 08e10d7d8..753c6a6e2 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/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', + }, }; diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 1afc233be..c1b107748 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/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', + }, }; diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 390012f88..b6b5119f5 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/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}}日', + }, }; diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index 661720c67..4256d07a1 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/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; -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 diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index bfa833daa..dad18a1ee 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/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; -// 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; diff --git a/frontend/src/pages/ProfilesPage.tsx b/frontend/src/pages/ProfilesPage.tsx index 30bfd3873..f6cf422ea 100644 --- a/frontend/src/pages/ProfilesPage.tsx +++ b/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 && (
- {t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), t)} + {t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), 'system', t)}
)} {t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })} diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index b76317d32..4a39c33f8 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, 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): 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 && ( - {formatRelativeTime(item.scheduled_time, timeFormat, t)} + {item.scheduled_time ? formatRelativeTime(item.scheduled_time, timeFormat, t) : t?.('queue.time.asap') ?? 'ASAP'} )}
diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts index 14bcd4baf..3df2e246f 100644 --- a/frontend/src/utils/date.ts +++ b/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; + +/** + * 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); +} \ No newline at end of file diff --git a/frontend/src/utils/file.ts b/frontend/src/utils/file.ts new file mode 100644 index 000000000..a1e79d927 --- /dev/null +++ b/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]}`; +}