Просмотр исходного кода

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

maziggy 4 месяцев назад
Родитель
Сommit
da2d768002

+ 6 - 0
CHANGELOG.md

@@ -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
frontend/public/sw.js

@@ -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
frontend/src/components/AMSHistoryModal.tsx

@@ -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'

+ 13 - 3
frontend/src/components/NotificationLogViewer.tsx

@@ -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
frontend/src/pages/ArchivesPage.tsx

@@ -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
frontend/src/pages/PrintersPage.tsx

@@ -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
frontend/src/pages/ProjectDetailPage.tsx

@@ -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
frontend/src/pages/QueuePage.tsx

@@ -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
frontend/src/pages/SystemInfoPage.tsx

@@ -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 - 0
frontend/src/utils/date.ts

@@ -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);
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-B03CK04P.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CMSE_lmd.js


+ 1 - 1
static/index.html

@@ -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
static/sw.js

@@ -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 = [

Некоторые файлы не были показаны из-за большого количества измененных файлов