Browse Source

Issue #66 summary: Archive times showing in UTC instead of local timezone

  Problem: The backend stores timestamps in UTC, but when sending them to the frontend without timezone indicators, JavaScript interprets them inconsistently. Users saw UTC times displayed directly instead of converted to their local timezone (e.g., America/New_York).

  Solution implemented:

  1. Created /frontend/src/utils/date.ts with utilities:
    - parseUTCDate(dateStr) - Parses backend timestamps as UTC by appending 'Z' to strings without timezone indicators
    - formatDate(dateStr, options) - Formats UTC timestamps to local date/time
    - formatDateOnly(dateStr, options) - Formats UTC timestamps to local date-only
  2. Updated 15 frontend files to use the new utilities:
    - ArchivesPage.tsx - Archive cards and list view dates
    - PrintersPage.tsx - Last print date
    - SettingsPage.tsx - API key last used date
    - QueuePage.tsx - Scheduled print times
    - ProjectDetailPage.tsx - Due dates and timeline
    - ProfilesPage.tsx - Relative time display
    - CalendarView.tsx - Calendar grouping by local date
    - NotificationLogViewer.tsx - Log timestamps
    - NotificationProviderCard.tsx - Last success date
    - AMSHistoryModal.tsx - Chart data timestamps
    - FilamentTrends.tsx - Usage data grouping
    - PrinterQueueWidget.tsx - Queue item dates
maziggy 4 months ago
parent
commit
42149b90ab

+ 14 - 10
frontend/src/components/AMSHistoryModal.tsx

@@ -13,6 +13,7 @@ import {
   ReferenceLine,
   ReferenceLine,
 } from 'recharts';
 } from 'recharts';
 import { api, type AMSHistoryResponse } from '../api/client';
 import { api, type AMSHistoryResponse } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 
 
@@ -79,16 +80,19 @@ export function AMSHistoryModal({
   if (!isOpen) return null;
   if (!isOpen) return null;
 
 
   // Format data for chart
   // Format data for chart
-  const chartData = data?.data.map(point => ({
-    time: new Date(point.recorded_at).getTime(),
-    humidity: point.humidity,
-    temperature: point.temperature,
-    timeLabel: new Date(point.recorded_at).toLocaleTimeString([], {
-      hour: '2-digit',
-      minute: '2-digit',
-      ...(hours > 24 ? { day: 'numeric', month: 'short' } : {}),
-    }),
-  })) || [];
+  const chartData = data?.data.map(point => {
+    const date = parseUTCDate(point.recorded_at) || new Date();
+    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' } : {}),
+      }),
+    };
+  }) || [];
 
 
   // Get thresholds
   // Get thresholds
   const humidityGood = thresholds?.humidityGood || 40;
   const humidityGood = thresholds?.humidityGood || 40;

+ 3 - 2
frontend/src/components/CalendarView.tsx

@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
 import { ChevronLeft, ChevronRight } from 'lucide-react';
 import { ChevronLeft, ChevronRight } from 'lucide-react';
 import type { Archive } from '../api/client';
 import type { Archive } from '../api/client';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 
 
 interface CalendarViewProps {
 interface CalendarViewProps {
   archives: Archive[];
   archives: Archive[];
@@ -31,11 +32,11 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
   const [selectedDate, setSelectedDate] = useState<string | null>(null);
   const [selectedDate, setSelectedDate] = useState<string | null>(null);
   const [selectedArchiveId, setSelectedArchiveId] = useState<number | null>(null);
   const [selectedArchiveId, setSelectedArchiveId] = useState<number | null>(null);
 
 
-  // Group archives by date
+  // Group archives by date (using local timezone from UTC timestamps)
   const archivesByDate = useMemo(() => {
   const archivesByDate = useMemo(() => {
     const map = new Map<string, Archive[]>();
     const map = new Map<string, Archive[]>();
     archives.forEach(archive => {
     archives.forEach(archive => {
-      const date = new Date(archive.completed_at || archive.created_at);
+      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
       const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
       const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
       const existing = map.get(key) || [];
       const existing = map.get(key) || [];
       existing.push(archive);
       existing.push(archive);

+ 8 - 6
frontend/src/components/FilamentTrends.tsx

@@ -15,6 +15,7 @@ import {
   Legend,
   Legend,
 } from 'recharts';
 } from 'recharts';
 import type { Archive } from '../api/client';
 import type { Archive } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 
 
 interface FilamentTrendsProps {
 interface FilamentTrendsProps {
   archives: Archive[];
   archives: Archive[];
@@ -47,7 +48,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
   // Filter archives by time range
   // Filter archives by time range
   const filteredArchives = useMemo(() => {
   const filteredArchives = useMemo(() => {
     const startDate = getDateRange(timeRange);
     const startDate = getDateRange(timeRange);
-    return archives.filter(a => new Date(a.completed_at || a.created_at) >= startDate);
+    return archives.filter(a => (parseUTCDate(a.completed_at || a.created_at) || new Date(0)) >= startDate);
   }, [archives, timeRange]);
   }, [archives, timeRange]);
 
 
   // Calculate daily usage data
   // Calculate daily usage data
@@ -55,8 +56,9 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
     const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
     const dataMap = new Map<string, { date: string; filament: number; cost: number; prints: number }>();
 
 
     filteredArchives.forEach(archive => {
     filteredArchives.forEach(archive => {
-      const date = new Date(archive.completed_at || archive.created_at);
-      const key = date.toISOString().split('T')[0];
+      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
+      // Use local date string for grouping
+      const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
 
 
       const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
       const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
       existing.filament += archive.filament_used_grams || 0;
       existing.filament += archive.filament_used_grams || 0;
@@ -80,11 +82,11 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
     const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
     const dataMap = new Map<string, { week: string; filament: number; cost: number; prints: number }>();
 
 
     filteredArchives.forEach(archive => {
     filteredArchives.forEach(archive => {
-      const date = new Date(archive.completed_at || archive.created_at);
+      const date = parseUTCDate(archive.completed_at || archive.created_at) || new Date();
       // Get week start (Sunday)
       // Get week start (Sunday)
       const weekStart = new Date(date);
       const weekStart = new Date(date);
       weekStart.setDate(date.getDate() - date.getDay());
       weekStart.setDate(date.getDate() - date.getDay());
-      const key = weekStart.toISOString().split('T')[0];
+      const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
 
 
       const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
       const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
       existing.filament += archive.filament_used_grams || 0;
       existing.filament += archive.filament_used_grams || 0;
@@ -132,7 +134,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
       const monthStr = monthDate.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
       const monthStr = monthDate.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
 
 
       const monthArchives = archives.filter(a => {
       const monthArchives = archives.filter(a => {
-        const d = new Date(a.completed_at || a.created_at);
+        const d = parseUTCDate(a.completed_at || a.created_at) || new Date(0);
         return d >= monthDate && d <= monthEnd;
         return d >= monthDate && d <= monthEnd;
       });
       });
 
 

+ 4 - 2
frontend/src/components/NotificationLogViewer.tsx

@@ -2,6 +2,7 @@ import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { History, CheckCircle, XCircle, Loader2, Trash2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
 import { History, CheckCircle, XCircle, Loader2, Trash2, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { parseUTCDate, formatDate as formatDateUtil } from '../utils/date';
 import type { NotificationLogEntry } from '../api/client';
 import type { NotificationLogEntry } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
@@ -70,7 +71,8 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
   });
   });
 
 
   const formatDate = (dateStr: string) => {
   const formatDate = (dateStr: string) => {
-    const date = new Date(dateStr);
+    const date = parseUTCDate(dateStr);
+    if (!date) return '';
     const now = new Date();
     const now = new Date();
     const diff = now.getTime() - date.getTime();
     const diff = now.getTime() - date.getTime();
 
 
@@ -278,7 +280,7 @@ function LogEntry({
           )}
           )}
           <div className="flex gap-4 text-xs text-bambu-gray pt-1">
           <div className="flex gap-4 text-xs text-bambu-gray pt-1">
             <span>Provider: {log.provider_type}</span>
             <span>Provider: {log.provider_type}</span>
-            <span>Time: {new Date(log.created_at).toLocaleString()}</span>
+            <span>Time: {formatDateUtil(log.created_at)}</span>
           </div>
           </div>
         </div>
         </div>
       )}
       )}

+ 3 - 2
frontend/src/components/NotificationProviderCard.tsx

@@ -2,6 +2,7 @@ import { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp, Calendar } from 'lucide-react';
 import { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp, Calendar } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { formatDateOnly, parseUTCDate } from '../utils/date';
 import type { NotificationProvider, NotificationProviderUpdate } from '../api/client';
 import type { NotificationProvider, NotificationProviderUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -90,11 +91,11 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {/* Quick enable/disable toggle + Status indicator */}
             {/* Quick enable/disable toggle + Status indicator */}
             <div className="flex items-center gap-3">
             <div className="flex items-center gap-3">
               {provider.last_success && (
               {provider.last_success && (
-                <span className="text-xs text-bambu-green hidden sm:inline">Last: {new Date(provider.last_success).toLocaleDateString()}</span>
+                <span className="text-xs text-bambu-green hidden sm:inline">Last: {formatDateOnly(provider.last_success)}</span>
               )}
               )}
               {/* Only show error if it's more recent than last success */}
               {/* Only show error if it's more recent than last success */}
               {provider.last_error && provider.last_error_at && (
               {provider.last_error && provider.last_error_at && (
-                !provider.last_success || new Date(provider.last_error_at) > new Date(provider.last_success)
+                !provider.last_success || (parseUTCDate(provider.last_error_at)?.getTime() || 0) > (parseUTCDate(provider.last_success)?.getTime() || 0)
               ) && (
               ) && (
                 <span className="text-xs text-red-400" title={provider.last_error}>Error</span>
                 <span className="text-xs text-red-400" title={provider.last_error}>Error</span>
               )}
               )}

+ 3 - 1
frontend/src/components/PrinterQueueWidget.tsx

@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query';
 import { Clock, Calendar, ChevronRight } from 'lucide-react';
 import { Clock, Calendar, ChevronRight } from 'lucide-react';
 import { Link } from 'react-router-dom';
 import { Link } from 'react-router-dom';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 
 
 interface PrinterQueueWidgetProps {
 interface PrinterQueueWidgetProps {
   printerId: number;
   printerId: number;
@@ -9,7 +10,8 @@ interface PrinterQueueWidgetProps {
 
 
 function formatRelativeTime(dateString: string | null): string {
 function formatRelativeTime(dateString: string | null): string {
   if (!dateString) return 'ASAP';
   if (!dateString) return 'ASAP';
-  const date = new Date(dateString);
+  const date = parseUTCDate(dateString);
+  if (!date) return 'ASAP';
   const now = new Date();
   const now = new Date();
   const diff = date.getTime() - now.getTime();
   const diff = date.getTime() - now.getTime();
 
 

+ 7 - 14
frontend/src/pages/ArchivesPage.tsx

@@ -43,6 +43,7 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
 import { openInSlicer } from '../utils/slicer';
+import { formatDate, formatDateOnly, parseUTCDate } from '../utils/date';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive, ProjectListItem } from '../api/client';
 import type { Archive, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
@@ -77,15 +78,7 @@ function formatDuration(seconds: number): string {
   return `${minutes}m`;
   return `${minutes}m`;
 }
 }
 
 
-function formatDate(dateStr: string): string {
-  return new Date(dateStr).toLocaleDateString('en-US', {
-    year: 'numeric',
-    month: 'short',
-    day: 'numeric',
-    hour: '2-digit',
-    minute: '2-digit',
-  });
-}
+// formatDate imported from '../utils/date' - handles UTC conversion
 
 
 function ArchiveCard({
 function ArchiveCard({
   archive,
   archive,
@@ -1365,7 +1358,7 @@ function ArchiveListRow({
           {printerName}
           {printerName}
         </div>
         </div>
         <div className="col-span-2 text-sm text-bambu-gray">
         <div className="col-span-2 text-sm text-bambu-gray">
-          {new Date(archive.created_at).toLocaleDateString()}
+          {formatDateOnly(archive.created_at)}
         </div>
         </div>
         <div className="col-span-1 text-sm text-bambu-gray">
         <div className="col-span-1 text-sm text-bambu-gray">
           {formatFileSize(archive.file_size)}
           {formatFileSize(archive.file_size)}
@@ -1551,7 +1544,7 @@ function ArchiveListRow({
                   <div className="text-sm text-gray-400 flex gap-3">
                   <div className="text-sm text-gray-400 flex gap-3">
                     <span>{formatFileSize(file.size)}</span>
                     <span>{formatFileSize(file.size)}</span>
                     {file.mtime && (
                     {file.mtime && (
-                      <span>{new Date(file.mtime).toLocaleDateString()}</span>
+                      <span>{formatDateOnly(file.mtime)}</span>
                     )}
                     )}
                   </div>
                   </div>
                 </button>
                 </button>
@@ -1817,7 +1810,7 @@ export function ArchivesPage() {
     ?.filter((a) => {
     ?.filter((a) => {
       // Collection filter
       // Collection filter
       const now = new Date();
       const now = new Date();
-      const archiveDate = new Date(a.created_at);
+      const archiveDate = parseUTCDate(a.created_at) || new Date(0);
       let matchesCollection = true;
       let matchesCollection = true;
 
 
       switch (collection) {
       switch (collection) {
@@ -1876,9 +1869,9 @@ export function ArchivesPage() {
     .sort((a, b) => {
     .sort((a, b) => {
       switch (sortBy) {
       switch (sortBy) {
         case 'date-desc':
         case 'date-desc':
-          return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
+          return (parseUTCDate(b.created_at)?.getTime() || 0) - (parseUTCDate(a.created_at)?.getTime() || 0);
         case 'date-asc':
         case 'date-asc':
-          return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
+          return (parseUTCDate(a.created_at)?.getTime() || 0) - (parseUTCDate(b.created_at)?.getTime() || 0);
         case 'name-asc':
         case 'name-asc':
           return (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
           return (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
         case 'name-desc':
         case 'name-desc':

+ 2 - 1
frontend/src/pages/PrintersPage.tsx

@@ -52,6 +52,7 @@ const SkipObjectsIcon = ({ className }: { className?: string }) => (
 );
 );
 import { useNavigate } from 'react-router-dom';
 import { useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi } from '../api/client';
 import { api, discoveryApi, firmwareApi } from '../api/client';
+import { formatDateOnly } from '../utils/date';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus } from '../api/client';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -1617,7 +1618,7 @@ function PrinterCard({
                               Last: {lastPrint.print_name || lastPrint.filename}
                               Last: {lastPrint.print_name || lastPrint.filename}
                               {lastPrint.completed_at && (
                               {lastPrint.completed_at && (
                                 <span className="ml-1 text-bambu-gray/60">
                                 <span className="ml-1 text-bambu-gray/60">
-                                  • {new Date(lastPrint.completed_at).toLocaleDateString([], { month: 'short', day: 'numeric' })}
+                                  • {formatDateOnly(lastPrint.completed_at, { month: 'short', day: 'numeric' })}
                                 </span>
                                 </span>
                               )}
                               )}
                             </p>
                             </p>

+ 3 - 1
frontend/src/pages/ProfilesPage.tsx

@@ -41,6 +41,7 @@ import {
   Plus as PlusIcon,
   Plus as PlusIcon,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition } from '../api/client';
 import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -86,7 +87,8 @@ function isUserPreset(settingId: string): boolean {
 
 
 // Format relative time
 // Format relative time
 function formatRelativeTime(dateStr: string): string {
 function formatRelativeTime(dateStr: string): string {
-  const date = new Date(dateStr);
+  const date = parseUTCDate(dateStr);
+  if (!date) return '';
   const now = new Date();
   const now = new Date();
   const diffMs = now.getTime() - date.getTime();
   const diffMs = now.getTime() - date.getTime();
   const diffMins = Math.floor(diffMs / 60000);
   const diffMins = Math.floor(diffMs / 60000);

+ 5 - 5
frontend/src/pages/ProjectDetailPage.tsx

@@ -32,6 +32,7 @@ import {
   ShoppingCart,
   ShoppingCart,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { parseUTCDate, formatDateOnly, formatDate as formatDateUtil } from '../utils/date';
 import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client';
 import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -175,13 +176,13 @@ function PriorityBadge({ priority }: { priority: string }) {
 
 
 function formatDate(dateString: string | null): string {
 function formatDate(dateString: string | null): string {
   if (!dateString) return '';
   if (!dateString) return '';
-  const date = new Date(dateString);
-  return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+  return formatDateOnly(dateString, { year: 'numeric', month: 'short', day: 'numeric' });
 }
 }
 
 
 function getDueDateStatus(dateString: string | null): { color: string; label: string } | null {
 function getDueDateStatus(dateString: string | null): { color: string; label: string } | null {
   if (!dateString) return null;
   if (!dateString) return null;
-  const dueDate = new Date(dateString);
+  const dueDate = parseUTCDate(dateString);
+  if (!dueDate) return null;
   const now = new Date();
   const now = new Date();
   const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
   const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
 
 
@@ -402,8 +403,7 @@ export function ProjectDetailPage() {
   });
   });
 
 
   const formatTimelineDate = (timestamp: string) => {
   const formatTimelineDate = (timestamp: string) => {
-    const date = new Date(timestamp);
-    return date.toLocaleDateString(undefined, {
+    return formatDateUtil(timestamp, {
       month: 'short',
       month: 'short',
       day: 'numeric',
       day: 'numeric',
       hour: '2-digit',
       hour: '2-digit',

+ 3 - 1
frontend/src/pages/QueuePage.tsx

@@ -43,6 +43,7 @@ import {
   Hand,
   Hand,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 import type { PrintQueueItem } from '../api/client';
 import type { PrintQueueItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -61,7 +62,8 @@ function formatDuration(seconds: number | null | undefined): string {
 
 
 function formatRelativeTime(dateString: string | null): string {
 function formatRelativeTime(dateString: string | null): string {
   if (!dateString) return 'ASAP';
   if (!dateString) return 'ASAP';
-  const date = new Date(dateString);
+  const date = parseUTCDate(dateString);
+  if (!date) return 'ASAP';
   const now = new Date();
   const now = new Date();
   const diff = date.getTime() - now.getTime();
   const diff = date.getTime() - now.getTime();
 
 

+ 2 - 1
frontend/src/pages/SettingsPage.tsx

@@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder } from 'lucide-react';
 import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { formatDateOnly } from '../utils/date';
 import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
 import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -1943,7 +1944,7 @@ export function SettingsPage() {
                             <p className="text-white font-medium">{key.name}</p>
                             <p className="text-white font-medium">{key.name}</p>
                             <p className="text-xs text-bambu-gray">
                             <p className="text-xs text-bambu-gray">
                               {key.key_prefix}••••••••
                               {key.key_prefix}••••••••
-                              {key.last_used && ` · Last used: ${new Date(key.last_used).toLocaleDateString()}`}
+                              {key.last_used && ` · Last used: ${formatDateOnly(key.last_used)}`}
                             </p>
                             </p>
                           </div>
                           </div>
                         </div>
                         </div>

+ 74 - 0
frontend/src/utils/date.ts

@@ -0,0 +1,74 @@
+/**
+ * Date utilities for handling UTC timestamps from the backend.
+ *
+ * The backend stores all timestamps in UTC without timezone indicators.
+ * These utilities ensure dates are properly interpreted as UTC and
+ * displayed in the user's local timezone.
+ */
+
+/**
+ * Parse a date string from the backend as UTC.
+ * Handles ISO 8601 strings with or without timezone indicators.
+ *
+ * @param dateStr - Date string from backend (e.g., "2026-01-09T12:03:36.288768")
+ * @returns Date object in local timezone
+ */
+export function parseUTCDate(dateStr: string | null | undefined): Date | null {
+  if (!dateStr) return null;
+
+  // If the string already has a timezone indicator, parse as-is
+  if (dateStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(dateStr)) {
+    return new Date(dateStr);
+  }
+
+  // Otherwise, append 'Z' to interpret as UTC
+  return new Date(dateStr + 'Z');
+}
+
+/**
+ * Format a UTC date string to a localized date/time string.
+ *
+ * @param dateStr - Date string from backend
+ * @param options - Intl.DateTimeFormat options (defaults to showing date and time)
+ * @returns Formatted date string in user's locale and timezone
+ */
+export function formatDate(
+  dateStr: string | null | undefined,
+  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',
+  };
+
+  return date.toLocaleString(undefined, options ?? defaultOptions);
+}
+
+/**
+ * Format a UTC date string to a localized date-only string.
+ *
+ * @param dateStr - Date string from backend
+ * @param options - Intl.DateTimeFormat options
+ * @returns Formatted date string in user's locale and timezone
+ */
+export function formatDateOnly(
+  dateStr: string | null | undefined,
+  options?: Intl.DateTimeFormatOptions
+): string {
+  const date = parseUTCDate(dateStr);
+  if (!date) return '';
+
+  const defaultOptions: Intl.DateTimeFormatOptions = {
+    year: 'numeric',
+    month: 'short',
+    day: 'numeric',
+  };
+
+  return date.toLocaleDateString(undefined, options ?? defaultOptions);
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CN6OA8A6.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-7FRtmIRx.js"></script>
+    <script type="module" crossorigin src="/assets/index-CN6OA8A6.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BEtulymk.css">
     <link rel="stylesheet" crossorigin href="/assets/index-BEtulymk.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff