) => {
+ 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]}`;
+}