Fix time format setting not applied to UI date/time displays (#75)

- Add time format utilities to date.ts (applyTimeFormat, formatDateTime,
    formatTimeOnly, TimeFormat type)
  - PrintersPage: Pass timeFormat to formatETA for printer card ETA display
  - ArchivesPage: Add timeFormat prop to ArchiveCard for archive timestamps
  - AMSHistoryModal: Apply timeFormat to chart labels, axis ticks, and tooltip
  - ProjectDetailPage: Use formatDateTime for timeline dates
  - QueuePage: Pass timeFormat to formatRelativeTime for scheduled prints
  - SystemInfoPage: Use formatDateTime for boot time display
  - NotificationLogViewer: Apply timeFormat to notification timestamps
  - Bump service worker cache version to v17

Closes #75
This commit is contained in:
maziggy
2026-01-11 13:49:03 +01:00
parent f7f4d88222
commit da2d768002
14 changed files with 8835 additions and 8692 deletions
+6
View File
@@ -46,6 +46,12 @@ All notable changes to Bambuddy will be documented in this file.
- Updates Projects page and Project Detail page instantly
### Fixed
- **Time format setting not applied** - Fixed 24-hour time format not being respected across the UI:
- ETA display on printer cards now uses configured time format
- Archive cards and timelapse file lists respect the setting
- AMS history charts use the configured format
- Project timeline, queue page, notification logs, and system info all updated
- Settings > General > Time Format now works consistently everywhere
- **QR code endpoint** - Fixed 500 error on archive QR code generation:
- Added `qrcode[pil]` to requirements.txt
- Improved error handling for missing dependencies
+2 -2
View File
@@ -1,6 +1,6 @@
// Bambuddy Service Worker
const CACHE_NAME = 'bambuddy-v15';
const STATIC_CACHE = 'bambuddy-static-v15';
const CACHE_NAME = 'bambuddy-v17';
const STATIC_CACHE = 'bambuddy-static-v17';
// Static assets to cache on install
const STATIC_ASSETS = [
+22 -8
View File
@@ -13,7 +13,7 @@ import {
ReferenceLine,
} from 'recharts';
import { api, type AMSHistoryResponse } from '../api/client';
import { parseUTCDate } from '../utils/date';
import { parseUTCDate, applyTimeFormat, type TimeFormat } from '../utils/date';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
@@ -58,6 +58,13 @@ export function AMSHistoryModal({
const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode);
const isDark = themeMode === 'dark';
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const timeFormat: TimeFormat = settings?.time_format || 'system';
// Close on Escape key
useEffect(() => {
if (!isOpen) return;
@@ -82,15 +89,16 @@ export function AMSHistoryModal({
// Format data for chart
const chartData = data?.data.map(point => {
const date = parseUTCDate(point.recorded_at) || new Date();
const timeOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
...(hours > 24 ? { day: 'numeric', month: 'short' } : {}),
};
return {
time: date.getTime(),
humidity: point.humidity,
temperature: point.temperature,
timeLabel: date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
...(hours > 24 ? { day: 'numeric', month: 'short' } : {}),
}),
timeLabel: date.toLocaleTimeString([], applyTimeFormat(timeOptions, timeFormat)),
};
}) || [];
@@ -311,7 +319,7 @@ export function AMSHistoryModal({
if (hours > 24) {
return date.toLocaleDateString([], { day: 'numeric', month: 'short' });
}
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return date.toLocaleTimeString([], applyTimeFormat({ hour: '2-digit', minute: '2-digit' }, timeFormat));
}}
stroke={isDark ? '#9ca3af' : '#6b7280'}
tick={{ fontSize: 12 }}
@@ -329,7 +337,13 @@ export function AMSHistoryModal({
borderRadius: '8px',
color: isDark ? '#fff' : '#000',
}}
labelFormatter={(ts) => new Date(ts).toLocaleString()}
labelFormatter={(ts) => new Date(ts).toLocaleString(undefined, applyTimeFormat({
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}, timeFormat))}
formatter={(value: number) => [
mode === 'humidity' ? `${value}%` : `${value}°C`,
mode === 'humidity' ? 'Humidity' : 'Temperature'
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { History, CheckCircle, XCircle, Loader2, Trash2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatDate as formatDateUtil } from '../utils/date';
import { parseUTCDate, formatTimeOnly, formatDateTime, type TimeFormat } from '../utils/date';
import type { NotificationLogEntry } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
@@ -44,6 +44,13 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
const [expandedId, setExpandedId] = useState<number | null>(null);
const [showFailedOnly, setShowFailedOnly] = useState(false);
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const timeFormat: TimeFormat = settings?.time_format || 'system';
const { data: logs, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['notification-logs', days, showFailedOnly],
queryFn: () => api.getNotificationLogs({
@@ -80,7 +87,7 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return date.toLocaleDateString() + ' ' + formatTimeOnly(date, timeFormat);
};
return (
@@ -191,6 +198,7 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
isExpanded={expandedId === log.id}
onToggle={() => setExpandedId(expandedId === log.id ? null : log.id)}
formatDate={formatDate}
formatFullDate={(dateStr) => formatDateTime(dateStr, timeFormat)}
/>
))}
</div>
@@ -213,11 +221,13 @@ function LogEntry({
isExpanded,
onToggle,
formatDate,
formatFullDate,
}: {
log: NotificationLogEntry;
isExpanded: boolean;
onToggle: () => void;
formatDate: (date: string) => string;
formatFullDate: (date: string) => string;
}) {
return (
<div
@@ -280,7 +290,7 @@ function LogEntry({
)}
<div className="flex gap-4 text-xs text-bambu-gray pt-1">
<span>Provider: {log.provider_type}</span>
<span>Time: {formatDateUtil(log.created_at)}</span>
<span>Time: {formatFullDate(log.created_at)}</span>
</div>
</div>
)}
+13 -3
View File
@@ -43,7 +43,7 @@ import {
} from 'lucide-react';
import { api } from '../api/client';
import { openInSlicer } from '../utils/slicer';
import { formatDate, formatDateOnly, parseUTCDate } from '../utils/date';
import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat } from '../utils/date';
import { useIsMobile } from '../hooks/useIsMobile';
import type { Archive, ProjectListItem } from '../api/client';
import { Card, CardContent } from '../components/Card';
@@ -89,6 +89,7 @@ function ArchiveCard({
selectionMode,
projects,
isHighlighted,
timeFormat = 'system',
}: {
archive: Archive;
printerName: string;
@@ -97,6 +98,7 @@ function ArchiveCard({
selectionMode: boolean;
projects: ProjectListItem[] | undefined;
isHighlighted?: boolean;
timeFormat?: TimeFormat;
}) {
// Debug: log when card is highlighted
if (isHighlighted) {
@@ -669,7 +671,7 @@ function ArchiveCard({
{/* Date & Size */}
<div className="flex items-center justify-between text-xs text-bambu-gray border-t border-bambu-dark-tertiary pt-3">
<span>{formatDate(archive.created_at)}</span>
<span>{formatDateTime(archive.created_at, timeFormat)}</span>
<span>{formatFileSize(archive.file_size)}</span>
</div>
@@ -890,7 +892,7 @@ function ArchiveCard({
<p className="text-white font-medium truncate">{file.name}</p>
<p className="text-sm text-gray-400">
{formatFileSize(file.size)}
{file.mtime && ` • ${formatDate(file.mtime)}`}
{file.mtime && ` • ${formatDateTime(file.mtime, timeFormat)}`}
</p>
</div>
</button>
@@ -1721,6 +1723,13 @@ export function ArchivesPage() {
queryFn: () => api.getProjects(),
});
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const timeFormat: TimeFormat = settings?.time_format || 'system';
const bulkDeleteMutation = useMutation({
mutationFn: async (ids: number[]) => {
await Promise.all(ids.map((id) => api.deleteArchive(id)));
@@ -2449,6 +2458,7 @@ export function ArchivesPage() {
selectionMode={selectionMode}
projects={projects}
isHighlighted={archive.id === highlightedArchiveId}
timeFormat={timeFormat}
/>
))}
</div>
+16 -3
View File
@@ -644,7 +644,7 @@ function formatTime(seconds: number): string {
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
function formatETA(remainingMinutes: number): string {
function formatETA(remainingMinutes: number, timeFormat: 'system' | '12h' | '24h' = 'system'): string {
const now = new Date();
const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
const today = new Date();
@@ -652,7 +652,16 @@ function formatETA(remainingMinutes: number): string {
const etaDay = new Date(eta);
etaDay.setHours(0, 0, 0, 0);
const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
// Build time format options based on setting
const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
if (timeFormat === '12h') {
timeOptions.hour12 = true;
} else if (timeFormat === '24h') {
timeOptions.hour12 = false;
}
// 'system' leaves hour12 undefined, letting the browser decide
const timeStr = eta.toLocaleTimeString([], timeOptions);
// Check if it's tomorrow or later
const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
@@ -865,6 +874,7 @@ function PrinterCard({
amsThresholds,
spoolmanEnabled = false,
hasUnlinkedSpools = false,
timeFormat = 'system',
}: {
printer: Printer;
hideIfDisconnected?: boolean;
@@ -879,6 +889,7 @@ function PrinterCard({
};
spoolmanEnabled?: boolean;
hasUnlinkedSpools?: boolean;
timeFormat?: 'system' | '12h' | '24h';
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
@@ -1617,7 +1628,7 @@ function PrinterCard({
{formatTime(status.remaining_time * 60)}
</span>
<span className="text-bambu-green font-medium" title="Estimated completion time">
ETA {formatETA(status.remaining_time)}
ETA {formatETA(status.remaining_time, timeFormat)}
</span>
</>
)}
@@ -3986,6 +3997,7 @@ export function PrintersPage() {
} : undefined}
spoolmanEnabled={spoolmanEnabled}
hasUnlinkedSpools={hasUnlinkedSpools}
timeFormat={settings?.time_format || 'system'}
/>
))}
</div>
@@ -4011,6 +4023,7 @@ export function PrintersPage() {
tempGood: Number(settings.ams_temp_good) || 28,
tempFair: Number(settings.ams_temp_fair) || 35,
} : undefined}
timeFormat={settings?.time_format || 'system'}
/>
))}
</div>
+3 -2
View File
@@ -32,7 +32,7 @@ import {
ShoppingCart,
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate, formatDateOnly, formatDate as formatDateUtil } from '../utils/date';
import { parseUTCDate, formatDateOnly, formatDateTime, type TimeFormat } from '../utils/date';
import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
@@ -235,6 +235,7 @@ export function ProjectDetailPage() {
});
const currency = settings?.currency || '$';
const timeFormat: TimeFormat = settings?.time_format || 'system';
const updateMutation = useMutation({
mutationFn: (data: ProjectUpdate) => api.updateProject(projectId, data),
@@ -403,7 +404,7 @@ export function ProjectDetailPage() {
});
const formatTimelineDate = (timestamp: string) => {
return formatDateUtil(timestamp, {
return formatDateTime(timestamp, timeFormat, {
month: 'short',
day: 'numeric',
hour: '2-digit',
+16 -4
View File
@@ -43,7 +43,7 @@ import {
Hand,
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate } from '../utils/date';
import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
import type { PrintQueueItem } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
@@ -60,7 +60,7 @@ function formatDuration(seconds: number | null | undefined): string {
return `${minutes}m`;
}
function formatRelativeTime(dateString: string | null): string {
function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system'): string {
if (!dateString) return 'ASAP';
const date = parseUTCDate(dateString);
if (!date) return 'ASAP';
@@ -72,7 +72,7 @@ function formatRelativeTime(dateString: string | null): string {
if (diff < 60000) return 'In less than a minute';
if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
if (diff < 86400000) return `In ${Math.round(diff / 3600000)} hours`;
return date.toLocaleString();
return formatDateTime(dateString, timeFormat);
}
function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
@@ -105,6 +105,7 @@ function SortableQueueItem({
onStop,
onRequeue,
onStart,
timeFormat = 'system',
}: {
item: PrintQueueItem;
position?: number;
@@ -114,6 +115,7 @@ function SortableQueueItem({
onStop: () => void;
onRequeue: () => void;
onStart: () => void;
timeFormat?: TimeFormat;
}) {
const {
attributes,
@@ -206,7 +208,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)}
{formatRelativeTime(item.scheduled_time, timeFormat)}
</span>
)}
</div>
@@ -377,6 +379,13 @@ export function QueuePage() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const timeFormat: TimeFormat = settings?.time_format || 'system';
const { data: queue, isLoading } = useQuery({
queryKey: ['queue', filterPrinter, filterStatus],
queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
@@ -659,6 +668,7 @@ export function QueuePage() {
onStop={() => setConfirmAction({ type: 'stop', item })}
onRequeue={() => {}}
onStart={() => {}}
timeFormat={timeFormat}
/>
))}
</div>
@@ -722,6 +732,7 @@ export function QueuePage() {
onStop={() => {}}
onRequeue={() => {}}
onStart={() => startMutation.mutate(item.id)}
timeFormat={timeFormat}
/>
))}
</div>
@@ -774,6 +785,7 @@ export function QueuePage() {
onStop={() => {}}
onRequeue={() => setRequeueItem(item)}
onStart={() => {}}
timeFormat={timeFormat}
/>
))}
</div>
+9 -1
View File
@@ -23,6 +23,7 @@ import {
} from 'lucide-react';
import { api, supportApi } from '../api/client';
import { Card } from '../components/Card';
import { formatDateTime, type TimeFormat } from '../utils/date';
function StatCard({
icon: Icon,
@@ -102,6 +103,13 @@ export function SystemInfoPage() {
refetchInterval: 10 * 1000,
});
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const timeFormat: TimeFormat = settings?.time_format || 'system';
const handleToggleDebugLogging = async () => {
setDebugToggling(true);
try {
@@ -525,7 +533,7 @@ export function SystemInfoPage() {
<StatCard
icon={Clock}
label={t('system.bootTime', 'Boot Time')}
value={new Date(systemInfo.system.boot_time).toLocaleString()}
value={formatDateTime(systemInfo.system.boot_time, timeFormat)}
/>
</div>
</Section>
+69
View File
@@ -6,6 +6,25 @@
* displayed in the user's local timezone.
*/
export type TimeFormat = 'system' | '12h' | '24h';
/**
* Apply time format setting to Intl.DateTimeFormatOptions.
* This modifies the options object in place and returns it.
*/
export function applyTimeFormat(
options: Intl.DateTimeFormatOptions,
timeFormat: TimeFormat = 'system'
): Intl.DateTimeFormatOptions {
if (timeFormat === '12h') {
options.hour12 = true;
} else if (timeFormat === '24h') {
options.hour12 = false;
}
// 'system' leaves hour12 undefined, letting the browser decide
return options;
}
/**
* Parse a date string from the backend as UTC.
* Handles ISO 8601 strings with or without timezone indicators.
@@ -72,3 +91,53 @@ export function formatDateOnly(
return date.toLocaleDateString(undefined, options ?? defaultOptions);
}
/**
* Format a UTC date string to a localized date/time string with time format support.
*
* @param dateStr - Date string from backend
* @param timeFormat - Time format setting ('system', '12h', '24h')
* @param options - Intl.DateTimeFormat options (defaults to showing date and time)
* @returns Formatted date string in user's locale and timezone
*/
export function formatDateTime(
dateStr: string | null | undefined,
timeFormat: TimeFormat = 'system',
options?: Intl.DateTimeFormatOptions
): string {
const date = parseUTCDate(dateStr);
if (!date) return '';
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
const finalOptions = applyTimeFormat(options ?? defaultOptions, timeFormat);
return date.toLocaleString(undefined, finalOptions);
}
/**
* Format a Date object to a localized time string with time format support.
*
* @param date - Date object
* @param timeFormat - Time format setting ('system', '12h', '24h')
* @param options - Additional Intl.DateTimeFormat options
* @returns Formatted time string
*/
export function formatTimeOnly(
date: Date,
timeFormat: TimeFormat = 'system',
options?: Intl.DateTimeFormatOptions
): string {
const defaultOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
};
const finalOptions = applyTimeFormat({ ...defaultOptions, ...options }, timeFormat);
return date.toLocaleTimeString([], finalOptions);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CMSE_lmd.js"></script>
<script type="module" crossorigin src="/assets/index-B03CK04P.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-48h_gFgJ.css">
</head>
<body>
+2 -2
View File
@@ -1,6 +1,6 @@
// Bambuddy Service Worker
const CACHE_NAME = 'bambuddy-v15';
const STATIC_CACHE = 'bambuddy-static-v15';
const CACHE_NAME = 'bambuddy-v16';
const STATIC_CACHE = 'bambuddy-static-v16';
// Static assets to cache on install
const STATIC_ASSETS = [