ソースを参照

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 ヶ月 前
コミット
42149b90ab

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

@@ -13,6 +13,7 @@ import {
   ReferenceLine,
 } from 'recharts';
 import { api, type AMSHistoryResponse } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 
@@ -79,16 +80,19 @@ export function AMSHistoryModal({
   if (!isOpen) return null;
 
   // 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
   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 type { Archive } from '../api/client';
 import { api } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 
 interface CalendarViewProps {
   archives: Archive[];
@@ -31,11 +32,11 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
   const [selectedDate, setSelectedDate] = useState<string | 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 map = new Map<string, 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 existing = map.get(key) || [];
       existing.push(archive);

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

@@ -15,6 +15,7 @@ import {
   Legend,
 } from 'recharts';
 import type { Archive } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 
 interface FilamentTrendsProps {
   archives: Archive[];
@@ -47,7 +48,7 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
   // Filter archives by time range
   const filteredArchives = useMemo(() => {
     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]);
 
   // 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 }>();
 
     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 };
       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 }>();
 
     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)
       const weekStart = new Date(date);
       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 };
       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 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;
       });
 

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

@@ -2,6 +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 type { NotificationLogEntry } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
@@ -70,7 +71,8 @@ export function NotificationLogViewer({ onClose }: NotificationLogViewerProps) {
   });
 
   const formatDate = (dateStr: string) => {
-    const date = new Date(dateStr);
+    const date = parseUTCDate(dateStr);
+    if (!date) return '';
     const now = new Date();
     const diff = now.getTime() - date.getTime();
 
@@ -278,7 +280,7 @@ function LogEntry({
           )}
           <div className="flex gap-4 text-xs text-bambu-gray pt-1">
             <span>Provider: {log.provider_type}</span>
-            <span>Time: {new Date(log.created_at).toLocaleString()}</span>
+            <span>Time: {formatDateUtil(log.created_at)}</span>
           </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 { Bell, Trash2, Settings2, Edit2, Send, Loader2, CheckCircle, XCircle, Moon, Clock, ChevronDown, ChevronUp, Calendar } from 'lucide-react';
 import { api } from '../api/client';
+import { formatDateOnly, parseUTCDate } from '../utils/date';
 import type { NotificationProvider, NotificationProviderUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
@@ -90,11 +91,11 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {/* Quick enable/disable toggle + Status indicator */}
             <div className="flex items-center gap-3">
               {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 */}
               {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>
               )}

+ 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 { Link } from 'react-router-dom';
 import { api } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 
 interface PrinterQueueWidgetProps {
   printerId: number;
@@ -9,7 +10,8 @@ interface PrinterQueueWidgetProps {
 
 function formatRelativeTime(dateString: string | null): string {
   if (!dateString) return 'ASAP';
-  const date = new Date(dateString);
+  const date = parseUTCDate(dateString);
+  if (!date) return 'ASAP';
   const now = new Date();
   const diff = date.getTime() - now.getTime();
 

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

@@ -43,6 +43,7 @@ import {
 } from 'lucide-react';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
+import { formatDate, formatDateOnly, parseUTCDate } from '../utils/date';
 import { useIsMobile } from '../hooks/useIsMobile';
 import type { Archive, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
@@ -77,15 +78,7 @@ function formatDuration(seconds: number): string {
   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({
   archive,
@@ -1365,7 +1358,7 @@ function ArchiveListRow({
           {printerName}
         </div>
         <div className="col-span-2 text-sm text-bambu-gray">
-          {new Date(archive.created_at).toLocaleDateString()}
+          {formatDateOnly(archive.created_at)}
         </div>
         <div className="col-span-1 text-sm text-bambu-gray">
           {formatFileSize(archive.file_size)}
@@ -1551,7 +1544,7 @@ function ArchiveListRow({
                   <div className="text-sm text-gray-400 flex gap-3">
                     <span>{formatFileSize(file.size)}</span>
                     {file.mtime && (
-                      <span>{new Date(file.mtime).toLocaleDateString()}</span>
+                      <span>{formatDateOnly(file.mtime)}</span>
                     )}
                   </div>
                 </button>
@@ -1817,7 +1810,7 @@ export function ArchivesPage() {
     ?.filter((a) => {
       // Collection filter
       const now = new Date();
-      const archiveDate = new Date(a.created_at);
+      const archiveDate = parseUTCDate(a.created_at) || new Date(0);
       let matchesCollection = true;
 
       switch (collection) {
@@ -1876,9 +1869,9 @@ export function ArchivesPage() {
     .sort((a, b) => {
       switch (sortBy) {
         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':
-          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':
           return (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
         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 { api, discoveryApi, firmwareApi } from '../api/client';
+import { formatDateOnly } from '../utils/date';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -1617,7 +1618,7 @@ function PrinterCard({
                               Last: {lastPrint.print_name || lastPrint.filename}
                               {lastPrint.completed_at && (
                                 <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>
                               )}
                             </p>

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

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

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

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

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

@@ -43,6 +43,7 @@ import {
   Hand,
 } from 'lucide-react';
 import { api } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 import type { PrintQueueItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -61,7 +62,8 @@ function formatDuration(seconds: number | null | undefined): string {
 
 function formatRelativeTime(dateString: string | null): string {
   if (!dateString) return 'ASAP';
-  const date = new Date(dateString);
+  const date = parseUTCDate(dateString);
+  if (!date) return 'ASAP';
   const now = new Date();
   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 { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
+import { formatDateOnly } from '../utils/date';
 import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
@@ -1943,7 +1944,7 @@ export function SettingsPage() {
                             <p className="text-white font-medium">{key.name}</p>
                             <p className="text-xs text-bambu-gray">
                               {key.key_prefix}••••••••
-                              {key.last_used && ` · Last used: ${new Date(key.last_used).toLocaleDateString()}`}
+                              {key.last_used && ` · Last used: ${formatDateOnly(key.last_used)}`}
                             </p>
                           </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);
+}

ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-CN6OA8A6.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-7FRtmIRx.js"></script>
+    <script type="module" crossorigin src="/assets/index-CN6OA8A6.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BEtulymk.css">
   </head>
   <body>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません