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
This commit is contained in:
AneoPsy
2026-02-17 01:15:33 -10:00
parent a4fad2d2f5
commit b477fbf46d
19 changed files with 652 additions and 122 deletions
@@ -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();
});
});
});
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)}
@@ -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>
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]}`;
}