Browse Source

Fix timestamps off by timezone offset in non-UTC containers (#504)

All backend timestamps used datetime.now() (server local time) or the
deprecated datetime.utcnow(). The frontend's parseUTCDate() assumes
timestamps without timezone indicators are UTC and appends 'Z', so
stored timestamps were off by the timezone offset when the container's
timezone wasn't UTC.

Backend: replaced datetime.now() and datetime.utcnow() with
datetime.now(timezone.utc) across 16 files (~80 call sites) for all
database fields and DB comparisons. Cosmetic timestamps (filenames,
user-facing local time formatting) intentionally left as local time.

Frontend: replaced 13 new Date(backendTimestamp) calls with
parseUTCDate() across 8 files to correctly interpret UTC timestamps.
maziggy 3 months ago
parent
commit
ed36eafbec

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.1b4] - Unreleased
 ## [0.2.1b4] - Unreleased
 
 
 ### Fixed
 ### Fixed
+- **Timestamps Off by Timezone Offset in Non-UTC Docker Containers** ([#504](https://github.com/maziggy/bambuddy/issues/504)) — All backend timestamps used `datetime.now()` (server local time) or the deprecated `datetime.utcnow()`. The frontend's `parseUTCDate()` assumes timestamps without timezone indicators are UTC and appends `'Z'`, so when the container's timezone wasn't UTC, every stored timestamp was off by the timezone offset. Replaced all database and comparison timestamps with `datetime.now(timezone.utc)` across 16 backend files (~80 call sites). On the frontend, replaced 13 `new Date(backendTimestamp)` calls with `parseUTCDate()` across 8 files to correctly interpret UTC timestamps. Cosmetic timestamps (filenames, user-facing local time formatting) are intentionally left as local time.
 - **"Power Off Printer" Option Not Gated by Control Permission** ([#500](https://github.com/maziggy/bambuddy/issues/500)) — The "Power off printer when done" checkbox in the print modal and the auto power off toggle in the bulk edit modal were accessible to all users regardless of permissions. Users without the `printers:control` permission can now no longer enable auto power off — the checkbox and tri-state toggle are disabled and visually dimmed.
 - **"Power Off Printer" Option Not Gated by Control Permission** ([#500](https://github.com/maziggy/bambuddy/issues/500)) — The "Power off printer when done" checkbox in the print modal and the auto power off toggle in the bulk edit modal were accessible to all users regardless of permissions. Users without the `printers:control` permission can now no longer enable auto power off — the checkbox and tri-state toggle are disabled and visually dimmed.
 - **Created Admin Users Can't See Settings Button** ([#503](https://github.com/maziggy/bambuddy/issues/503)) — The sidebar hid the Settings link based on a hardcoded `role === 'user'` check instead of the actual `settings:read` permission, so newly created admin users who had the permission still couldn't see the button. Also, after login the auth state was set directly from the login response instead of re-fetching the full auth status, which could miss permission data. Now uses `hasPermission('settings:read')` for the sidebar check and calls `checkAuthStatus()` after login to load the complete user state including permissions.
 - **Created Admin Users Can't See Settings Button** ([#503](https://github.com/maziggy/bambuddy/issues/503)) — The sidebar hid the Settings link based on a hardcoded `role === 'user'` check instead of the actual `settings:read` permission, so newly created admin users who had the permission still couldn't see the button. Also, after login the auth state was set directly from the login response instead of re-fetching the full auth status, which could miss permission data. Now uses `hasPermission('settings:read')` for the sidebar check and calls `checkAuthStatus()` after login to load the complete user state including permissions.
 
 

+ 3 - 3
backend/app/api/routes/ams_history.py

@@ -1,6 +1,6 @@
 """API routes for AMS sensor history."""
 """API routes for AMS sensor history."""
 
 
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 
 from fastapi import APIRouter, Depends, Query
 from fastapi import APIRouter, Depends, Query
 from pydantic import BaseModel
 from pydantic import BaseModel
@@ -44,7 +44,7 @@ async def get_ams_history(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
 ):
     """Get AMS sensor history for a specific printer and AMS unit."""
     """Get AMS sensor history for a specific printer and AMS unit."""
-    since = datetime.now() - timedelta(hours=hours)
+    since = datetime.now(timezone.utc) - timedelta(hours=hours)
 
 
     # Get data points
     # Get data points
     result = await db.execute(
     result = await db.execute(
@@ -108,7 +108,7 @@ async def delete_old_history(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
 ):
     """Delete old AMS history data for a printer."""
     """Delete old AMS history data for a printer."""
-    cutoff = datetime.now() - timedelta(days=days)
+    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
 
 
     result = await db.execute(
     result = await db.execute(
         select(func.count(AMSSensorHistory.id)).where(
         select(func.count(AMSSensorHistory.id)).where(

+ 2 - 2
backend/app/api/routes/archives.py

@@ -1438,11 +1438,11 @@ async def scan_timelapse(
     # Strategy 4: If only one timelapse exists and archive was recently completed, use it
     # Strategy 4: If only one timelapse exists and archive was recently completed, use it
     # This handles cases where printer clock is wrong or timezone issues exist
     # This handles cases where printer clock is wrong or timezone issues exist
     if not matching_file and len(video_files) == 1:
     if not matching_file and len(video_files) == 1:
-        from datetime import datetime, timedelta
+        from datetime import datetime, timedelta, timezone
 
 
         archive_completed = archive.completed_at or archive.created_at
         archive_completed = archive.completed_at or archive.created_at
         if archive_completed:
         if archive_completed:
-            time_since_completion = datetime.now() - archive_completed
+            time_since_completion = datetime.now(timezone.utc) - archive_completed
             # If archive was completed within the last hour, assume the single timelapse is for it
             # If archive was completed within the last hour, assume the single timelapse is for it
             if time_since_completion < timedelta(hours=1):
             if time_since_completion < timedelta(hours=1):
                 matching_file = video_files[0]
                 matching_file = video_files[0]

+ 3 - 3
backend/app/api/routes/maintenance.py

@@ -1,7 +1,7 @@
 """Maintenance tracking API routes."""
 """Maintenance tracking API routes."""
 
 
 import logging
 import logging
-from datetime import datetime
+from datetime import datetime, timezone
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
@@ -303,7 +303,7 @@ async def _get_printer_maintenance_internal(
     due_count = 0
     due_count = 0
     warning_count = 0
     warning_count = 0
 
 
-    now = datetime.utcnow()
+    now = datetime.now(timezone.utc)
 
 
     for maint_type in all_types:
     for maint_type in all_types:
         # Skip system types that don't apply to this printer model
         # Skip system types that don't apply to this printer model
@@ -591,7 +591,7 @@ async def perform_maintenance(
     db.add(history)
     db.add(history)
 
 
     # Update item
     # Update item
-    item.last_performed_at = datetime.utcnow()
+    item.last_performed_at = datetime.now(timezone.utc)
     item.last_performed_hours = current_hours
     item.last_performed_hours = current_hours
 
 
     await db.commit()
     await db.commit()

+ 8 - 8
backend/app/api/routes/notifications.py

@@ -2,7 +2,7 @@
 
 
 import json
 import json
 import logging
 import logging
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import delete, desc, func, select
 from sqlalchemy import delete, desc, func, select
@@ -194,11 +194,11 @@ async def test_all_notification_providers(
 
 
         # Update provider status
         # Update provider status
         if success:
         if success:
-            provider.last_success = datetime.utcnow()
+            provider.last_success = datetime.now(timezone.utc)
             success_count += 1
             success_count += 1
         else:
         else:
             provider.last_error = message
             provider.last_error = message
-            provider.last_error_at = datetime.utcnow()
+            provider.last_error_at = datetime.now(timezone.utc)
             failed_count += 1
             failed_count += 1
 
 
         results.append(
         results.append(
@@ -248,7 +248,7 @@ async def get_notification_logs(
     if success is not None:
     if success is not None:
         query = query.where(NotificationLog.success == success)
         query = query.where(NotificationLog.success == success)
     if days is not None:
     if days is not None:
-        cutoff = datetime.utcnow() - timedelta(days=days)
+        cutoff = datetime.now(timezone.utc) - timedelta(days=days)
         query = query.where(NotificationLog.created_at >= cutoff)
         query = query.where(NotificationLog.created_at >= cutoff)
 
 
     query = query.offset(offset).limit(limit)
     query = query.offset(offset).limit(limit)
@@ -295,7 +295,7 @@ async def get_notification_log_stats(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
 ):
 ):
     """Get notification log statistics."""
     """Get notification log statistics."""
-    cutoff = datetime.utcnow() - timedelta(days=days)
+    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
 
 
     # Total counts
     # Total counts
     total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
     total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
@@ -341,7 +341,7 @@ async def clear_notification_logs(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
 ):
 ):
     """Clear old notification logs."""
     """Clear old notification logs."""
-    cutoff = datetime.utcnow() - timedelta(days=older_than_days)
+    cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
 
 
     result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
     result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
     await db.commit()
     await db.commit()
@@ -446,10 +446,10 @@ async def test_notification_provider(
 
 
     # Update provider status
     # Update provider status
     if success:
     if success:
-        provider.last_success = datetime.utcnow()
+        provider.last_success = datetime.now(timezone.utc)
     else:
     else:
         provider.last_error = message
         provider.last_error = message
-        provider.last_error_at = datetime.utcnow()
+        provider.last_error_at = datetime.now(timezone.utc)
 
 
     await db.commit()
     await db.commit()
 
 

+ 3 - 3
backend/app/api/routes/print_queue.py

@@ -3,7 +3,7 @@
 import json
 import json
 import logging
 import logging
 import zipfile
 import zipfile
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 
 
 import defusedxml.ElementTree as ET
 import defusedxml.ElementTree as ET
@@ -732,7 +732,7 @@ async def cancel_queue_item(
         raise HTTPException(400, f"Cannot cancel item with status '{item.status}'")
         raise HTTPException(400, f"Cannot cancel item with status '{item.status}'")
 
 
     item.status = "cancelled"
     item.status = "cancelled"
-    item.completed_at = datetime.now()
+    item.completed_at = datetime.now(timezone.utc)
     await db.commit()
     await db.commit()
 
 
     logger.info("Cancelled queue item %s", item_id)
     logger.info("Cancelled queue item %s", item_id)
@@ -775,7 +775,7 @@ async def stop_queue_item(
 
 
     # Update queue item status regardless - if printer is off, print is already stopped
     # Update queue item status regardless - if printer is off, print is already stopped
     item.status = "cancelled"
     item.status = "cancelled"
-    item.completed_at = datetime.now()
+    item.completed_at = datetime.now(timezone.utc)
     item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)"
     item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)"
     await db.commit()
     await db.commit()
 
 

+ 6 - 6
backend/app/api/routes/smart_plugs.py

@@ -1,7 +1,7 @@
 """API routes for smart plug management."""
 """API routes for smart plug management."""
 
 
 import logging
 import logging
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 
 from fastapi import APIRouter, Body, Depends, HTTPException
 from fastapi import APIRouter, Body, Depends, HTTPException
 from pydantic import BaseModel
 from pydantic import BaseModel
@@ -605,7 +605,7 @@ async def control_smart_plug(
         elif expected_state == "OFF" and plug.printer_id:
         elif expected_state == "OFF" and plug.printer_id:
             # Mark printer offline immediately for faster UI update
             # Mark printer offline immediately for faster UI update
             printer_manager.mark_printer_offline(plug.printer_id)
             printer_manager.mark_printer_offline(plug.printer_id)
-    plug.last_checked = datetime.utcnow()
+    plug.last_checked = datetime.now(timezone.utc)
     await db.commit()
     await db.commit()
 
 
     # Trigger associated scripts if this is a main (non-script) plug
     # Trigger associated scripts if this is a main (non-script) plug
@@ -692,7 +692,7 @@ async def get_plug_status(
             # Update last state in database
             # Update last state in database
             if is_reachable and data.state:
             if is_reachable and data.state:
                 plug.last_state = data.state
                 plug.last_state = data.state
-                plug.last_checked = datetime.utcnow()
+                plug.last_checked = datetime.now(timezone.utc)
                 await db.commit()
                 await db.commit()
 
 
             energy_data = None
             energy_data = None
@@ -727,7 +727,7 @@ async def get_plug_status(
     # Update last state in database
     # Update last state in database
     if status["reachable"]:
     if status["reachable"]:
         plug.last_state = status["state"]
         plug.last_state = status["state"]
-        plug.last_checked = datetime.utcnow()
+        plug.last_checked = datetime.now(timezone.utc)
         await db.commit()
         await db.commit()
 
 
     # Fetch energy data if device is reachable
     # Fetch energy data if device is reachable
@@ -756,7 +756,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
     # Cooldown: don't alert more than once per 5 minutes
     # Cooldown: don't alert more than once per 5 minutes
     cooldown_minutes = 5
     cooldown_minutes = 5
     if plug.power_alert_last_triggered:
     if plug.power_alert_last_triggered:
-        time_since_last = datetime.utcnow() - plug.power_alert_last_triggered
+        time_since_last = datetime.now(timezone.utc) - plug.power_alert_last_triggered
         if time_since_last < timedelta(minutes=cooldown_minutes):
         if time_since_last < timedelta(minutes=cooldown_minutes):
             return
             return
 
 
@@ -777,7 +777,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
         threshold = plug.power_alert_low
         threshold = plug.power_alert_low
 
 
     if alert_triggered:
     if alert_triggered:
-        plug.power_alert_last_triggered = datetime.utcnow()
+        plug.power_alert_last_triggered = datetime.now(timezone.utc)
         await db.commit()
         await db.commit()
 
 
         # Send notification
         # Send notification

+ 9 - 9
backend/app/core/auth.py

@@ -3,7 +3,7 @@ from __future__ import annotations
 import logging
 import logging
 import os
 import os
 import secrets
 import secrets
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from pathlib import Path
 from pathlib import Path
 from typing import Annotated
 from typing import Annotated
 
 
@@ -108,7 +108,7 @@ SLICER_TOKEN_EXPIRE_MINUTES = 5
 def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
 def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
     """Create a short-lived download token for slicer protocol handlers."""
     """Create a short-lived download token for slicer protocol handlers."""
     # Cleanup expired tokens
     # Cleanup expired tokens
-    now = datetime.utcnow()
+    now = datetime.now(timezone.utc)
     expired = [k for k, (_, exp) in _slicer_tokens.items() if exp < now]
     expired = [k for k, (_, exp) in _slicer_tokens.items() if exp < now]
     for k in expired:
     for k in expired:
         del _slicer_tokens[k]
         del _slicer_tokens[k]
@@ -125,7 +125,7 @@ def verify_slicer_download_token(token: str, resource_type: str, resource_id: in
     if not entry:
     if not entry:
         return False
         return False
     resource_key, expiry = entry
     resource_key, expiry = entry
-    if datetime.utcnow() > expiry:
+    if datetime.now(timezone.utc) > expiry:
         del _slicer_tokens[token]
         del _slicer_tokens[token]
         return False
         return False
     expected_key = f"{resource_type}:{resource_id}"
     expected_key = f"{resource_type}:{resource_id}"
@@ -156,9 +156,9 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
     """Create a JWT access token."""
     """Create a JWT access token."""
     to_encode = data.copy()
     to_encode = data.copy()
     if expires_delta:
     if expires_delta:
-        expire = datetime.utcnow() + expires_delta
+        expire = datetime.now(timezone.utc) + expires_delta
     else:
     else:
-        expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+        expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
     to_encode.update({"exp": expire})
     to_encode.update({"exp": expire})
     encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
     encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
     return encoded_jwt
     return encoded_jwt
@@ -235,10 +235,10 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
         for api_key in api_keys:
         for api_key in api_keys:
             if verify_password(api_key_value, api_key.key_hash):
             if verify_password(api_key_value, api_key.key_hash):
                 # Check expiration
                 # Check expiration
-                if api_key.expires_at and api_key.expires_at < datetime.now():
+                if api_key.expires_at and api_key.expires_at < datetime.now(timezone.utc):
                     return None  # Expired
                     return None  # Expired
                 # Update last_used timestamp
                 # Update last_used timestamp
-                api_key.last_used = datetime.now()
+                api_key.last_used = datetime.now(timezone.utc)
                 await db.commit()
                 await db.commit()
                 return api_key
                 return api_key
     except Exception as e:
     except Exception as e:
@@ -451,13 +451,13 @@ async def get_api_key(
         # Check if key matches (verify against hash)
         # Check if key matches (verify against hash)
         if verify_password(api_key_value, api_key.key_hash):
         if verify_password(api_key_value, api_key.key_hash):
             # Check expiration
             # Check expiration
-            if api_key.expires_at and api_key.expires_at < datetime.now():
+            if api_key.expires_at and api_key.expires_at < datetime.now(timezone.utc):
                 raise HTTPException(
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
                     status_code=status.HTTP_401_UNAUTHORIZED,
                     detail="API key has expired",
                     detail="API key has expired",
                 )
                 )
             # Update last_used timestamp
             # Update last_used timestamp
-            api_key.last_used = datetime.now()
+            api_key.last_used = datetime.now(timezone.utc)
             await db.commit()
             await db.commit()
             return api_key
             return api_key
 
 

+ 9 - 9
backend/app/main.py

@@ -1262,7 +1262,7 @@ async def on_print_start(printer_id: int, data: dict):
             if archive:
             if archive:
                 # Update archive status to printing
                 # Update archive status to printing
                 archive.status = "printing"
                 archive.status = "printing"
-                archive.started_at = datetime.now()
+                archive.started_at = datetime.now(timezone.utc)
                 await db.commit()
                 await db.commit()
 
 
                 # Track as active print
                 # Track as active print
@@ -1561,7 +1561,7 @@ async def on_print_start(printer_id: int, data: dict):
                     file_size=0,
                     file_size=0,
                     print_name=print_name,
                     print_name=print_name,
                     status="printing",
                     status="printing",
-                    started_at=datetime.now(),
+                    started_at=datetime.now(timezone.utc),
                     extra_data={"no_3mf_available": True, "original_subtask": subtask_name, "_print_data": data},
                     extra_data={"no_3mf_available": True, "original_subtask": subtask_name, "_print_data": data},
                 )
                 )
 
 
@@ -2212,7 +2212,7 @@ async def on_print_complete(printer_id: int, data: dict):
             if queue_item:
             if queue_item:
                 queue_status = data.get("status", "completed")
                 queue_status = data.get("status", "completed")
                 queue_item.status = queue_status
                 queue_item.status = queue_status
-                queue_item.completed_at = datetime.now()
+                queue_item.completed_at = datetime.now(timezone.utc)
                 await db.commit()
                 await db.commit()
                 logger.info("Updated queue item %s status to %s", queue_item.id, queue_status)
                 logger.info("Updated queue item %s status to %s", queue_item.id, queue_status)
 
 
@@ -2239,7 +2239,7 @@ async def on_print_complete(printer_id: int, data: dict):
                     pending_count = count_result.scalar() or 0
                     pending_count = count_result.scalar() or 0
 
 
                     if pending_count == 0:
                     if pending_count == 0:
-                        today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+                        today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
                         completed_result = await db.execute(
                         completed_result = await db.execute(
                             select(sa_func.count(PrintQueueItem.id)).where(
                             select(sa_func.count(PrintQueueItem.id)).where(
                                 PrintQueueItem.status.in_(["completed", "failed", "skipped"]),
                                 PrintQueueItem.status.in_(["completed", "failed", "skipped"]),
@@ -2408,7 +2408,7 @@ async def on_print_complete(printer_id: int, data: dict):
             await service.update_archive_status(
             await service.update_archive_status(
                 archive_id,
                 archive_id,
                 status=status,
                 status=status,
-                completed_at=datetime.now() if status in ("completed", "failed", "aborted") else None,
+                completed_at=datetime.now(timezone.utc) if status in ("completed", "failed", "aborted") else None,
                 failure_reason=failure_reason,
                 failure_reason=failure_reason,
             )
             )
             logger.info(
             logger.info(
@@ -2998,7 +2998,7 @@ async def record_ams_history():
                         if humidity is not None and humidity > humidity_threshold:
                         if humidity is not None and humidity > humidity_threshold:
                             cooldown_key = f"{printer.id}:{ams_id}:humidity"
                             cooldown_key = f"{printer.id}:{ams_id}:humidity"
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
-                            now = datetime.now()
+                            now = datetime.now(timezone.utc)
                             if (
                             if (
                                 last_alarm is None
                                 last_alarm is None
                                 or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60
                                 or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60
@@ -3024,7 +3024,7 @@ async def record_ams_history():
                         if temperature is not None and temperature > temp_threshold:
                         if temperature is not None and temperature > temp_threshold:
                             cooldown_key = f"{printer.id}:{ams_id}:temperature"
                             cooldown_key = f"{printer.id}:{ams_id}:temperature"
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
-                            now = datetime.now()
+                            now = datetime.now(timezone.utc)
                             if (
                             if (
                                 last_alarm is None
                                 last_alarm is None
                                 or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60
                                 or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60
@@ -3062,7 +3062,7 @@ async def record_ams_history():
                     setting = result.scalar_one_or_none()
                     setting = result.scalar_one_or_none()
                     retention_days = int(setting.value) if setting else AMS_HISTORY_RETENTION_DAYS
                     retention_days = int(setting.value) if setting else AMS_HISTORY_RETENTION_DAYS
 
 
-                    cutoff = datetime.now() - timedelta(days=retention_days)
+                    cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
                     result = await db.execute(delete(AMSSensorHistory).where(AMSSensorHistory.recorded_at < cutoff))
                     result = await db.execute(delete(AMSSensorHistory).where(AMSSensorHistory.recorded_at < cutoff))
                     await db.commit()
                     await db.commit()
                     if result.rowcount > 0:
                     if result.rowcount > 0:
@@ -3118,7 +3118,7 @@ async def track_printer_runtime():
                 result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
                 result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
                 printers = result.scalars().all()
                 printers = result.scalars().all()
 
 
-                now = datetime.now()
+                now = datetime.now(timezone.utc)
                 updated_count = 0
                 updated_count = 0
 
 
                 needs_commit = False
                 needs_commit = False

+ 3 - 3
backend/app/services/archive.py

@@ -4,7 +4,7 @@ import logging
 import re
 import re
 import shutil
 import shutil
 import zipfile
 import zipfile
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 
 
 from defusedxml import ElementTree as ET
 from defusedxml import ElementTree as ET
@@ -893,8 +893,8 @@ class ArchiveService:
 
 
         # Determine status and timestamps
         # Determine status and timestamps
         status = print_data.get("status", "completed") if print_data else "archived"
         status = print_data.get("status", "completed") if print_data else "archived"
-        started_at = datetime.now() if status == "printing" else None
-        completed_at = datetime.now() if status in ("completed", "failed", "archived") else None
+        started_at = datetime.now(timezone.utc) if status == "printing" else None
+        completed_at = datetime.now(timezone.utc) if status in ("completed", "failed", "archived") else None
 
 
         # Calculate cost based on filament usage and type
         # Calculate cost based on filament usage and type
         cost = None
         cost = None

+ 6 - 6
backend/app/services/bambu_cloud.py

@@ -5,7 +5,7 @@ Handles authentication and profile management with Bambu Lab's cloud services.
 """
 """
 
 
 import logging
 import logging
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 
 import httpx
 import httpx
 
 
@@ -42,7 +42,7 @@ class BambuCloudService:
         """Check if we have a valid token."""
         """Check if we have a valid token."""
         if not self.access_token:
         if not self.access_token:
             return False
             return False
-        return not (self.token_expiry and datetime.now() > self.token_expiry)
+        return not (self.token_expiry and datetime.now(timezone.utc) > self.token_expiry)
 
 
     def _get_headers(self) -> dict:
     def _get_headers(self) -> dict:
         """Get headers for authenticated requests."""
         """Get headers for authenticated requests."""
@@ -200,9 +200,9 @@ class BambuCloudService:
             if response.status_code == 200 and access_token:
             if response.status_code == 200 and access_token:
                 self.access_token = access_token
                 self.access_token = access_token
                 self.refresh_token = data.get("refreshToken")
                 self.refresh_token = data.get("refreshToken")
-                from datetime import datetime, timedelta
+                from datetime import datetime, timedelta, timezone
 
 
-                self.token_expiry = datetime.now() + timedelta(days=30)
+                self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
                 return {"success": True, "message": "Login successful"}
                 return {"success": True, "message": "Login successful"}
 
 
             # Provide helpful error message
             # Provide helpful error message
@@ -224,12 +224,12 @@ class BambuCloudService:
         self.access_token = data.get("accessToken")
         self.access_token = data.get("accessToken")
         self.refresh_token = data.get("refreshToken")
         self.refresh_token = data.get("refreshToken")
         # Token typically valid for ~3 months, but we'll refresh more often
         # Token typically valid for ~3 months, but we'll refresh more often
-        self.token_expiry = datetime.now() + timedelta(days=30)
+        self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
 
 
     def set_token(self, access_token: str):
     def set_token(self, access_token: str):
         """Set access token directly (for stored tokens)."""
         """Set access token directly (for stored tokens)."""
         self.access_token = access_token
         self.access_token = access_token
-        self.token_expiry = datetime.now() + timedelta(days=30)
+        self.token_expiry = datetime.now(timezone.utc) + timedelta(days=30)
 
 
     def logout(self):
     def logout(self):
         """Clear authentication state."""
         """Clear authentication state."""

+ 3 - 3
backend/app/services/bambu_mqtt.py

@@ -16,7 +16,7 @@ import time
 from collections import deque
 from collections import deque
 from collections.abc import Callable
 from collections.abc import Callable
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
-from datetime import datetime
+from datetime import datetime, timezone
 
 
 import paho.mqtt.client as mqtt
 import paho.mqtt.client as mqtt
 
 
@@ -460,7 +460,7 @@ class BambuMQTTClient:
             if self._logging_enabled:
             if self._logging_enabled:
                 self._message_log.append(
                 self._message_log.append(
                     MQTTLogEntry(
                     MQTTLogEntry(
-                        timestamp=datetime.now().isoformat(),
+                        timestamp=datetime.now(timezone.utc).isoformat(),
                         topic=msg.topic,
                         topic=msg.topic,
                         direction="in",
                         direction="in",
                         payload=payload,
                         payload=payload,
@@ -2678,7 +2678,7 @@ class BambuMQTTClient:
             if self._logging_enabled:
             if self._logging_enabled:
                 self._message_log.append(
                 self._message_log.append(
                     MQTTLogEntry(
                     MQTTLogEntry(
-                        timestamp=datetime.now().isoformat(),
+                        timestamp=datetime.now(timezone.utc).isoformat(),
                         topic=self.topic_publish,
                         topic=self.topic_publish,
                         direction="out",
                         direction="out",
                         payload=command,
                         payload=command,

+ 6 - 6
backend/app/services/discovery.py

@@ -17,7 +17,7 @@ import re
 import socket
 import socket
 import struct
 import struct
 from dataclasses import dataclass
 from dataclasses import dataclass
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -304,7 +304,7 @@ class PrinterDiscoveryService:
             name=name,
             name=name,
             ip_address=ip_address,
             ip_address=ip_address,
             model=model,
             model=model,
-            discovered_at=datetime.now().isoformat(),
+            discovered_at=datetime.now(timezone.utc).isoformat(),
         )
         )
 
 
         self._discovered[serial] = printer
         self._discovered[serial] = printer
@@ -414,7 +414,7 @@ class SubnetScanner:
             name=name or f"Printer at {ip}",
             name=name or f"Printer at {ip}",
             ip_address=ip,
             ip_address=ip,
             model=model,
             model=model,
-            discovered_at=datetime.now().isoformat(),
+            discovered_at=datetime.now(timezone.utc).isoformat(),
         )
         )
         self._discovered[ip] = printer
         self._discovered[ip] = printer
 
 
@@ -609,7 +609,7 @@ class TasmotaScanner:
                             "name": f"Tasmota ({ip})",
                             "name": f"Tasmota ({ip})",
                             "module": None,
                             "module": None,
                             "state": "UNKNOWN",
                             "state": "UNKNOWN",
-                            "discovered_at": datetime.now().isoformat(),
+                            "discovered_at": datetime.now(timezone.utc).isoformat(),
                         }
                         }
                         self._discovered[ip] = device
                         self._discovered[ip] = device
                         return
                         return
@@ -627,7 +627,7 @@ class TasmotaScanner:
                             "name": f"Tasmota ({ip})",
                             "name": f"Tasmota ({ip})",
                             "module": None,
                             "module": None,
                             "state": "UNKNOWN",
                             "state": "UNKNOWN",
-                            "discovered_at": datetime.now().isoformat(),
+                            "discovered_at": datetime.now(timezone.utc).isoformat(),
                         }
                         }
                         self._discovered[ip] = device
                         self._discovered[ip] = device
                         return
                         return
@@ -668,7 +668,7 @@ class TasmotaScanner:
                     "name": device_name,
                     "name": device_name,
                     "module": module,
                     "module": module,
                     "state": power_state,
                     "state": power_state,
-                    "discovered_at": datetime.now().isoformat(),
+                    "discovered_at": datetime.now(timezone.utc).isoformat(),
                 }
                 }
 
 
                 self._discovered[ip] = device
                 self._discovered[ip] = device

+ 3 - 3
backend/app/services/failure_analysis.py

@@ -1,5 +1,5 @@
 from collections import defaultdict
 from collections import defaultdict
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 
 
 from sqlalchemy import and_, func, select
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,7 +30,7 @@ class FailureAnalysisService:
         Returns:
         Returns:
             Dictionary with failure analysis results
             Dictionary with failure analysis results
         """
         """
-        cutoff_date = datetime.utcnow() - timedelta(days=days)
+        cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
 
 
         # Build base query
         # Build base query
         base_filter = [PrintArchive.created_at >= cutoff_date]
         base_filter = [PrintArchive.created_at >= cutoff_date]
@@ -142,7 +142,7 @@ class FailureAnalysisService:
         # Failure rate trend (by week)
         # Failure rate trend (by week)
         trend_data = []
         trend_data = []
         for i in range(min(days // 7, 12)):  # Up to 12 weeks
         for i in range(min(days // 7, 12)):  # Up to 12 weeks
-            week_end = datetime.utcnow() - timedelta(weeks=i)
+            week_end = datetime.now(timezone.utc) - timedelta(weeks=i)
             week_start = week_end - timedelta(weeks=1)
             week_start = week_end - timedelta(weeks=1)
 
 
             week_filter = base_filter.copy()
             week_filter = base_filter.copy()

+ 20 - 20
backend/app/services/mqtt_relay.py

@@ -10,7 +10,7 @@ import logging
 import ssl
 import ssl
 import threading
 import threading
 import time
 import time
-from datetime import datetime
+from datetime import datetime, timezone
 from typing import Any
 from typing import Any
 
 
 import paho.mqtt.client as mqtt
 import paho.mqtt.client as mqtt
@@ -211,7 +211,7 @@ class MQTTRelayService:
         """Publish BamBuddy status (online/offline)."""
         """Publish BamBuddy status (online/offline)."""
         self._publish(
         self._publish(
             f"{self.topic_prefix}/status",
             f"{self.topic_prefix}/status",
-            {"status": status, "timestamp": datetime.utcnow().isoformat()},
+            {"status": status, "timestamp": datetime.now(timezone.utc).isoformat()},
             retain=True,
             retain=True,
         )
         )
 
 
@@ -257,7 +257,7 @@ class MQTTRelayService:
             "printer_id": printer_id,
             "printer_id": printer_id,
             "printer_name": printer_name,
             "printer_name": printer_name,
             "printer_serial": printer_serial,
             "printer_serial": printer_serial,
-            "timestamp": datetime.utcnow().isoformat(),
+            "timestamp": datetime.now(timezone.utc).isoformat(),
             "connected": state.connected,
             "connected": state.connected,
             "state": state.state,
             "state": state.state,
             "progress": state.progress,
             "progress": state.progress,
@@ -294,7 +294,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "printer_name": printer_name,
                 "printer_serial": printer_serial,
                 "printer_serial": printer_serial,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -309,7 +309,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "printer_name": printer_name,
                 "printer_serial": printer_serial,
                 "printer_serial": printer_serial,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -333,7 +333,7 @@ class MQTTRelayService:
                 "printer_serial": printer_serial,
                 "printer_serial": printer_serial,
                 "filename": filename,
                 "filename": filename,
                 "subtask_name": subtask_name,
                 "subtask_name": subtask_name,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -365,7 +365,7 @@ class MQTTRelayService:
                 "filename": filename,
                 "filename": filename,
                 "subtask_name": subtask_name,
                 "subtask_name": subtask_name,
                 "status": status,
                 "status": status,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -387,7 +387,7 @@ class MQTTRelayService:
                 "printer_name": printer_name,
                 "printer_name": printer_name,
                 "printer_serial": printer_serial,
                 "printer_serial": printer_serial,
                 "ams_units": ams_data,
                 "ams_units": ams_data,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -409,7 +409,7 @@ class MQTTRelayService:
                 "printer_name": printer_name,
                 "printer_name": printer_name,
                 "printer_serial": printer_serial,
                 "printer_serial": printer_serial,
                 "errors": errors,
                 "errors": errors,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -435,7 +435,7 @@ class MQTTRelayService:
                 "filename": filename,
                 "filename": filename,
                 "printer_id": printer_id,
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "printer_name": printer_name,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -459,7 +459,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "printer_name": printer_name,
                 "printer_serial": printer_serial,
                 "printer_serial": printer_serial,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -489,7 +489,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "printer_name": printer_name,
                 "status": status,
                 "status": status,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -517,7 +517,7 @@ class MQTTRelayService:
                 "maintenance_type": maintenance_type,
                 "maintenance_type": maintenance_type,
                 "current_value": current_value,
                 "current_value": current_value,
                 "threshold": threshold,
                 "threshold": threshold,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -537,7 +537,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "printer_name": printer_name,
                 "maintenance_type": maintenance_type,
                 "maintenance_type": maintenance_type,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -557,7 +557,7 @@ class MQTTRelayService:
                 "printer_id": printer_id,
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "printer_name": printer_name,
                 "maintenance_type": maintenance_type,
                 "maintenance_type": maintenance_type,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -583,7 +583,7 @@ class MQTTRelayService:
                 "print_name": print_name,
                 "print_name": print_name,
                 "printer_name": printer_name,
                 "printer_name": printer_name,
                 "status": status,
                 "status": status,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -603,7 +603,7 @@ class MQTTRelayService:
                 "archive_id": archive_id,
                 "archive_id": archive_id,
                 "print_name": print_name,
                 "print_name": print_name,
                 "status": status,
                 "status": status,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -629,7 +629,7 @@ class MQTTRelayService:
                 "spool_name": spool_name,
                 "spool_name": spool_name,
                 "remaining_weight": remaining_weight,
                 "remaining_weight": remaining_weight,
                 "remaining_percent": remaining_percent,
                 "remaining_percent": remaining_percent,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -659,7 +659,7 @@ class MQTTRelayService:
                 "state": state,
                 "state": state,
                 "printer_id": printer_id,
                 "printer_id": printer_id,
                 "printer_name": printer_name,
                 "printer_name": printer_name,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 
@@ -683,7 +683,7 @@ class MQTTRelayService:
                 "power_watts": power,
                 "power_watts": power,
                 "energy_today_kwh": energy_today,
                 "energy_today_kwh": energy_today,
                 "energy_total_kwh": energy_total,
                 "energy_total_kwh": energy_total,
-                "timestamp": datetime.utcnow().isoformat(),
+                "timestamp": datetime.now(timezone.utc).isoformat(),
             },
             },
         )
         )
 
 

+ 3 - 3
backend/app/services/mqtt_smart_plug.py

@@ -8,7 +8,7 @@ import json
 import logging
 import logging
 import threading
 import threading
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
 from typing import Any
 from typing import Any
 
 
 import paho.mqtt.client as mqtt
 import paho.mqtt.client as mqtt
@@ -262,7 +262,7 @@ class MQTTSmartPlugService:
                     self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
                     self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
 
 
                 data = self.plug_data[plug_id]
                 data = self.plug_data[plug_id]
-                data.last_seen = datetime.utcnow()
+                data.last_seen = datetime.now(timezone.utc)
 
 
                 # Process based on data type
                 # Process based on data type
                 if data_type == "power":
                 if data_type == "power":
@@ -473,7 +473,7 @@ class MQTTSmartPlugService:
             return False
             return False
 
 
         timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)
         timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)
-        return datetime.utcnow() - data.last_seen < timeout
+        return datetime.now(timezone.utc) - data.last_seen < timeout
 
 
     async def disconnect(self, timeout: float = 0):
     async def disconnect(self, timeout: float = 0):
         """Disconnect from MQTT broker."""
         """Disconnect from MQTT broker."""

+ 3 - 3
backend/app/services/notification_service.py

@@ -5,7 +5,7 @@ import json
 import logging
 import logging
 import re
 import re
 import smtplib
 import smtplib
-from datetime import datetime
+from datetime import datetime, timezone
 from email.mime.multipart import MIMEMultipart
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from email.mime.text import MIMEText
 from typing import Any
 from typing import Any
@@ -501,10 +501,10 @@ class NotificationService:
         provider = result.scalar_one_or_none()
         provider = result.scalar_one_or_none()
         if provider:
         if provider:
             if success:
             if success:
-                provider.last_success = datetime.utcnow()
+                provider.last_success = datetime.now(timezone.utc)
             else:
             else:
                 provider.last_error = error
                 provider.last_error = error
-                provider.last_error_at = datetime.utcnow()
+                provider.last_error_at = datetime.now(timezone.utc)
             await db.commit()
             await db.commit()
 
 
     async def _get_providers_for_event(
     async def _get_providers_for_event(

+ 13 - 13
backend/app/services/print_scheduler.py

@@ -4,7 +4,7 @@ import asyncio
 import json
 import json
 import logging
 import logging
 import zipfile
 import zipfile
-from datetime import datetime
+from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 
 
 import defusedxml.ElementTree as ET
 import defusedxml.ElementTree as ET
@@ -80,7 +80,7 @@ class PrintScheduler:
 
 
             for item in items:
             for item in items:
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
-                if item.scheduled_time and item.scheduled_time > datetime.utcnow():
+                if item.scheduled_time and item.scheduled_time > datetime.now(timezone.utc):
                     continue
                     continue
 
 
                 # Skip items that require manual start
                 # Skip items that require manual start
@@ -124,7 +124,7 @@ class PrintScheduler:
                         if not await self._check_previous_success(db, item):
                         if not await self._check_previous_success(db, item):
                             item.status = "skipped"
                             item.status = "skipped"
                             item.error_message = "Previous print failed or was aborted"
                             item.error_message = "Previous print failed or was aborted"
-                            item.completed_at = datetime.now()
+                            item.completed_at = datetime.now(timezone.utc)
                             await db.commit()
                             await db.commit()
                             logger.info("Skipped queue item %s - previous print failed", item.id)
                             logger.info("Skipped queue item %s - previous print failed", item.id)
 
 
@@ -201,7 +201,7 @@ class PrintScheduler:
                             if not await self._check_previous_success(db, item):
                             if not await self._check_previous_success(db, item):
                                 item.status = "skipped"
                                 item.status = "skipped"
                                 item.error_message = "Previous print failed or was aborted"
                                 item.error_message = "Previous print failed or was aborted"
-                                item.completed_at = datetime.now()
+                                item.completed_at = datetime.now(timezone.utc)
                                 await db.commit()
                                 await db.commit()
                                 logger.info("Skipped queue item %s - previous print failed", item.id)
                                 logger.info("Skipped queue item %s - previous print failed", item.id)
 
 
@@ -940,7 +940,7 @@ class PrintScheduler:
         if not printer:
         if not printer:
             item.status = "failed"
             item.status = "failed"
             item.error_message = "Printer not found"
             item.error_message = "Printer not found"
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             await db.commit()
             logger.error("Queue item %s: Printer %s not found", item.id, item.printer_id)
             logger.error("Queue item %s: Printer %s not found", item.id, item.printer_id)
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
@@ -950,7 +950,7 @@ class PrintScheduler:
         if not printer_manager.is_connected(item.printer_id):
         if not printer_manager.is_connected(item.printer_id):
             item.status = "failed"
             item.status = "failed"
             item.error_message = "Printer not connected"
             item.error_message = "Printer not connected"
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             await db.commit()
             logger.error("Queue item %s: Printer %s not connected", item.id, item.printer_id)
             logger.error("Queue item %s: Printer %s not connected", item.id, item.printer_id)
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
@@ -969,7 +969,7 @@ class PrintScheduler:
             if not archive:
             if not archive:
                 item.status = "failed"
                 item.status = "failed"
                 item.error_message = "Archive not found"
                 item.error_message = "Archive not found"
-                item.completed_at = datetime.utcnow()
+                item.completed_at = datetime.now(timezone.utc)
                 await db.commit()
                 await db.commit()
                 logger.error("Queue item %s: Archive %s not found", item.id, item.archive_id)
                 logger.error("Queue item %s: Archive %s not found", item.id, item.archive_id)
                 await self._power_off_if_needed(db, item)
                 await self._power_off_if_needed(db, item)
@@ -985,7 +985,7 @@ class PrintScheduler:
             if not library_file:
             if not library_file:
                 item.status = "failed"
                 item.status = "failed"
                 item.error_message = "Library file not found"
                 item.error_message = "Library file not found"
-                item.completed_at = datetime.utcnow()
+                item.completed_at = datetime.now(timezone.utc)
                 await db.commit()
                 await db.commit()
                 logger.error("Queue item %s: Library file %s not found", item.id, item.library_file_id)
                 logger.error("Queue item %s: Library file %s not found", item.id, item.library_file_id)
                 await self._power_off_if_needed(db, item)
                 await self._power_off_if_needed(db, item)
@@ -1021,7 +1021,7 @@ class PrintScheduler:
             # Neither archive nor library file specified
             # Neither archive nor library file specified
             item.status = "failed"
             item.status = "failed"
             item.error_message = "No source file specified"
             item.error_message = "No source file specified"
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             await db.commit()
             logger.error("Queue item %s: No archive_id or library_file_id specified", item.id)
             logger.error("Queue item %s: No archive_id or library_file_id specified", item.id)
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
@@ -1031,7 +1031,7 @@ class PrintScheduler:
         if not file_path.exists():
         if not file_path.exists():
             item.status = "failed"
             item.status = "failed"
             item.error_message = "Source file not found on disk"
             item.error_message = "Source file not found on disk"
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             await db.commit()
             logger.error("Queue item %s: File not found: %s", item.id, file_path)
             logger.error("Queue item %s: File not found: %s", item.id, file_path)
             await self._power_off_if_needed(db, item)
             await self._power_off_if_needed(db, item)
@@ -1106,7 +1106,7 @@ class PrintScheduler:
             )
             )
             item.status = "failed"
             item.status = "failed"
             item.error_message = error_msg
             item.error_message = error_msg
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             await db.commit()
             logger.error(
             logger.error(
                 f"Queue item {item.id}: FTP upload failed - printer={printer.name}, model={printer.model}, "
                 f"Queue item {item.id}: FTP upload failed - printer={printer.name}, model={printer.model}, "
@@ -1146,7 +1146,7 @@ class PrintScheduler:
         # in "printing" status without actually printing - but that's safer than
         # in "printing" status without actually printing - but that's safer than
         # accidentally reprinting the same file hours later.
         # accidentally reprinting the same file hours later.
         item.status = "printing"
         item.status = "printing"
-        item.started_at = datetime.utcnow()
+        item.started_at = datetime.now(timezone.utc)
         await db.commit()
         await db.commit()
 
 
         # Consume the plate-cleared flag now that we're starting a print
         # Consume the plate-cleared flag now that we're starting a print
@@ -1214,7 +1214,7 @@ class PrintScheduler:
             # Print command failed - revert status
             # Print command failed - revert status
             item.status = "failed"
             item.status = "failed"
             item.error_message = "Failed to send print command to printer"
             item.error_message = "Failed to send print command to printer"
-            item.completed_at = datetime.utcnow()
+            item.completed_at = datetime.now(timezone.utc)
             await db.commit()
             await db.commit()
             logger.error(
             logger.error(
                 f"Queue item {item.id}: Failed to start print on {printer.name} ({printer.model}) - "
                 f"Queue item {item.id}: Failed to start print on {printer.name} ({printer.model}) - "

+ 7 - 7
backend/app/services/smart_plug_manager.py

@@ -2,7 +2,7 @@
 
 
 import asyncio
 import asyncio
 import logging
 import logging
-from datetime import datetime
+from datetime import datetime, timezone
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 
 
 from sqlalchemy import select
 from sqlalchemy import select
@@ -112,7 +112,7 @@ class SmartPlugManager:
                         success = await service.turn_on(plug)
                         success = await service.turn_on(plug)
                         if success:
                         if success:
                             plug.last_state = "ON"
                             plug.last_state = "ON"
-                            plug.last_checked = datetime.utcnow()
+                            plug.last_checked = datetime.now(timezone.utc)
                             self._last_schedule_check[plug.id] = f"on:{current_time}"
                             self._last_schedule_check[plug.id] = f"on:{current_time}"
 
 
                 # Check if we should turn off
                 # Check if we should turn off
@@ -123,7 +123,7 @@ class SmartPlugManager:
                         success = await service.turn_off(plug)
                         success = await service.turn_off(plug)
                         if success:
                         if success:
                             plug.last_state = "OFF"
                             plug.last_state = "OFF"
-                            plug.last_checked = datetime.utcnow()
+                            plug.last_checked = datetime.now(timezone.utc)
                             self._last_schedule_check[plug.id] = f"off:{current_time}"
                             self._last_schedule_check[plug.id] = f"off:{current_time}"
                             # Mark printer offline if linked
                             # Mark printer offline if linked
                             if plug.printer_id:
                             if plug.printer_id:
@@ -164,7 +164,7 @@ class SmartPlugManager:
         if success:
         if success:
             # Update last state and reset auto_off_executed
             # Update last state and reset auto_off_executed
             plug.last_state = "ON"
             plug.last_state = "ON"
-            plug.last_checked = datetime.utcnow()
+            plug.last_checked = datetime.now(timezone.utc)
             plug.auto_off_executed = False  # Reset flag when turning on
             plug.auto_off_executed = False  # Reset flag when turning on
             await db.commit()
             await db.commit()
 
 
@@ -391,7 +391,7 @@ class SmartPlugManager:
                 plug = result.scalar_one_or_none()
                 plug = result.scalar_one_or_none()
                 if plug:
                 if plug:
                     plug.auto_off_pending = pending
                     plug.auto_off_pending = pending
-                    plug.auto_off_pending_since = datetime.utcnow() if pending else None
+                    plug.auto_off_pending_since = datetime.now(timezone.utc) if pending else None
                     await db.commit()
                     await db.commit()
                     logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
                     logger.debug("Marked plug %s auto_off_pending=%s", plug_id, pending)
         except Exception as e:
         except Exception as e:
@@ -412,7 +412,7 @@ class SmartPlugManager:
                     plug.auto_off_pending = False  # Clear pending state
                     plug.auto_off_pending = False  # Clear pending state
                     plug.auto_off_pending_since = None
                     plug.auto_off_pending_since = None
                     plug.last_state = "OFF"
                     plug.last_state = "OFF"
-                    plug.last_checked = datetime.utcnow()
+                    plug.last_checked = datetime.now(timezone.utc)
                     await db.commit()
                     await db.commit()
                     logger.info("Auto-off executed and disabled for plug %s", plug_id)
                     logger.info("Auto-off executed and disabled for plug %s", plug_id)
         except Exception as e:
         except Exception as e:
@@ -455,7 +455,7 @@ class SmartPlugManager:
                 for plug in pending_plugs:
                 for plug in pending_plugs:
                     # Check how long it's been pending (timeout after 2 hours)
                     # Check how long it's been pending (timeout after 2 hours)
                     if plug.auto_off_pending_since:
                     if plug.auto_off_pending_since:
-                        elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
+                        elapsed = (datetime.now(timezone.utc) - plug.auto_off_pending_since).total_seconds()
                         if elapsed > 7200:  # 2 hours
                         if elapsed > 7200:  # 2 hours
                             logger.warning(
                             logger.warning(
                                 f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
                                 f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "

+ 5 - 5
backend/tests/unit/services/test_smart_plug_manager.py

@@ -4,7 +4,7 @@ These tests specifically target the auto-off behavior and toggle functionality
 that were identified as common regression points.
 that were identified as common regression points.
 """
 """
 
 
-from datetime import datetime
+from datetime import datetime, timezone
 from unittest.mock import AsyncMock, MagicMock, patch
 from unittest.mock import AsyncMock, MagicMock, patch
 
 
 import pytest
 import pytest
@@ -354,7 +354,7 @@ class TestScheduleLoop:
             mock_now = MagicMock()
             mock_now = MagicMock()
             mock_now.strftime.return_value = "08:00"
             mock_now.strftime.return_value = "08:00"
             mock_datetime.now.return_value = mock_now
             mock_datetime.now.return_value = mock_now
-            mock_datetime.utcnow.return_value = datetime.utcnow()
+            mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
 
 
             # Set up async session mock
             # Set up async session mock
             mock_db = AsyncMock()
             mock_db = AsyncMock()
@@ -395,7 +395,7 @@ class TestScheduleLoop:
             mock_now = MagicMock()
             mock_now = MagicMock()
             mock_now.strftime.return_value = "22:00"
             mock_now.strftime.return_value = "22:00"
             mock_datetime.now.return_value = mock_now
             mock_datetime.now.return_value = mock_now
-            mock_datetime.utcnow.return_value = datetime.utcnow()
+            mock_datetime.utcnow.return_value = datetime.now(timezone.utc)
 
 
             # Set up async session mock
             # Set up async session mock
             mock_db = AsyncMock()
             mock_db = AsyncMock()
@@ -466,7 +466,7 @@ class TestPendingAutoOffPersistence:
         mock_plug.password = None
         mock_plug.password = None
         mock_plug.printer_id = 1
         mock_plug.printer_id = 1
         mock_plug.auto_off_pending = True
         mock_plug.auto_off_pending = True
-        mock_plug.auto_off_pending_since = datetime.utcnow()
+        mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
         mock_plug.off_delay_mode = "temperature"
         mock_plug.off_delay_mode = "temperature"
         mock_plug.off_temp_threshold = 70
         mock_plug.off_temp_threshold = 70
 
 
@@ -497,7 +497,7 @@ class TestPendingAutoOffPersistence:
         mock_plug.password = None
         mock_plug.password = None
         mock_plug.printer_id = 1
         mock_plug.printer_id = 1
         mock_plug.auto_off_pending = True
         mock_plug.auto_off_pending = True
-        mock_plug.auto_off_pending_since = datetime.utcnow()
+        mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
         mock_plug.off_delay_mode = "time"
         mock_plug.off_delay_mode = "time"
 
 
         with (
         with (

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

@@ -185,7 +185,7 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
             <div>
             <div>
               <div className="text-2xl font-bold text-white">
               <div className="text-2xl font-bold text-white">
                 {archives.filter(a => {
                 {archives.filter(a => {
-                  const d = new Date(a.completed_at || a.created_at);
+                  const d = parseUTCDate(a.completed_at || a.created_at) || new Date();
                   return d.getMonth() === currentMonth && d.getFullYear() === currentYear;
                   return d.getMonth() === currentMonth && d.getFullYear() === currentYear;
                 }).length}
                 }).length}
               </div>
               </div>
@@ -194,7 +194,7 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
             <div>
             <div>
               <div className="text-2xl font-bold text-green-400">
               <div className="text-2xl font-bold text-green-400">
                 {archives.filter(a => {
                 {archives.filter(a => {
-                  const d = new Date(a.completed_at || a.created_at);
+                  const d = parseUTCDate(a.completed_at || a.created_at) || new Date();
                   return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'completed';
                   return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'completed';
                 }).length}
                 }).length}
               </div>
               </div>
@@ -203,7 +203,7 @@ export function CalendarView({ archives, onArchiveClick, highlightedArchiveId }:
             <div>
             <div>
               <div className="text-2xl font-bold text-red-400">
               <div className="text-2xl font-bold text-red-400">
                 {archives.filter(a => {
                 {archives.filter(a => {
-                  const d = new Date(a.completed_at || a.created_at);
+                  const d = parseUTCDate(a.completed_at || a.created_at) || new Date();
                   return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'failed';
                   return d.getMonth() === currentMonth && d.getFullYear() === currentYear && a.status === 'failed';
                 }).length}
                 }).length}
               </div>
               </div>

+ 5 - 4
frontend/src/components/FileManagerModal.tsx

@@ -23,6 +23,7 @@ import {
   Box,
   Box,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { parseUTCDate } from '../utils/date';
 import { Button } from './Button';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
 import { ModelViewer } from './ModelViewer';
 import { ModelViewer } from './ModelViewer';
@@ -561,13 +562,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
                       case 'size-desc':
                       case 'size-desc':
                         return b.size - a.size;
                         return b.size - a.size;
                       case 'date-asc': {
                       case 'date-asc': {
-                        const aTime = a.mtime ? new Date(a.mtime).getTime() : 0;
-                        const bTime = b.mtime ? new Date(b.mtime).getTime() : 0;
+                        const aTime = a.mtime ? parseUTCDate(a.mtime)?.getTime() ?? 0 : 0;
+                        const bTime = b.mtime ? parseUTCDate(b.mtime)?.getTime() ?? 0 : 0;
                         return aTime - bTime;
                         return aTime - bTime;
                       }
                       }
                       case 'date-desc': {
                       case 'date-desc': {
-                        const aTime = a.mtime ? new Date(a.mtime).getTime() : 0;
-                        const bTime = b.mtime ? new Date(b.mtime).getTime() : 0;
+                        const aTime = a.mtime ? parseUTCDate(a.mtime)?.getTime() ?? 0 : 0;
+                        const bTime = b.mtime ? parseUTCDate(b.mtime)?.getTime() ?? 0 : 0;
                         return bTime - aTime;
                         return bTime - aTime;
                       }
                       }
                       default:
                       default:

+ 2 - 1
frontend/src/components/Layout.tsx

@@ -12,6 +12,7 @@ import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { Card, CardHeader, CardContent } from './Card';
 import { Card, CardHeader, CardContent } from './Card';
+import { parseUTCDate } from '../utils/date';
 import { Button } from './Button';
 import { Button } from './Button';
 
 
 interface NavItem {
 interface NavItem {
@@ -201,7 +202,7 @@ export function Layout() {
       setDebugDuration(null);
       setDebugDuration(null);
       return;
       return;
     }
     }
-    const enabledAt = new Date(debugLoggingState.enabled_at).getTime();
+    const enabledAt = parseUTCDate(debugLoggingState.enabled_at)?.getTime() ?? Date.now();
     const updateDuration = () => {
     const updateDuration = () => {
       setDebugDuration(Math.floor((Date.now() - enabledAt) / 1000));
       setDebugDuration(Math.floor((Date.now() - enabledAt) / 1000));
     };
     };

+ 2 - 2
frontend/src/components/PrintModal/index.tsx

@@ -10,7 +10,7 @@ import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
 import { isPlaceholderDate } from '../../utils/amsHelpers';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getCurrencySymbol } from '../../utils/currency';
-import { toDateTimeLocalValue } from '../../utils/date';
+import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
 import { Button } from '../Button';
 import { Button } from '../Button';
 import { Card, CardContent } from '../Card';
 import { Card, CardContent } from '../Card';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentMapping } from './FilamentMapping';
@@ -94,7 +94,7 @@ export function PrintModal({
 
 
       let scheduledTime = '';
       let scheduledTime = '';
       if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
       if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) {
-        const date = new Date(queueItem.scheduled_time);
+        const date = parseUTCDate(queueItem.scheduled_time) ?? new Date();
         // Use toDateTimeLocalValue to convert UTC to local time for datetime-local input
         // Use toDateTimeLocalValue to convert UTC to local time for datetime-local input
         scheduledTime = toDateTimeLocalValue(date);
         scheduledTime = toDateTimeLocalValue(date);
       }
       }

+ 2 - 2
frontend/src/pages/FileManagerPage.tsx

@@ -57,7 +57,7 @@ import { ModelViewerModal } from '../components/ModelViewerModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
-import { formatDuration } from '../utils/date';
+import { formatDuration, parseUTCDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
 import { formatFileSize } from '../utils/file';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
@@ -1260,7 +1260,7 @@ export function FileManagerPage() {
           comparison = (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
           comparison = (a.print_name || a.filename).localeCompare(b.print_name || b.filename);
           break;
           break;
         case 'date':
         case 'date':
-          comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
+          comparison = (parseUTCDate(a.created_at)?.getTime() ?? 0) - (parseUTCDate(b.created_at)?.getTime() ?? 0);
           break;
           break;
         case 'size':
         case 'size':
           comparison = a.file_size - b.file_size;
           comparison = a.file_size - b.file_size;

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

@@ -46,7 +46,7 @@ import {
 
 
 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, formatETA, formatDuration } from '../utils/date';
+import { formatDateOnly, formatETA, formatDuration, parseUTCDate } from '../utils/date';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } from '../api/client';
 import type { Printer, PrinterCreate, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment } 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';
@@ -3739,7 +3739,7 @@ function PrinterCard({
                         )}
                         )}
                         {/* Timestamp */}
                         {/* Timestamp */}
                         <p className="text-[10px] text-bambu-gray/60">
                         <p className="text-[10px] text-bambu-gray/60">
-                          {ref.timestamp ? new Date(ref.timestamp).toLocaleDateString() : ''}
+                          {ref.timestamp ? parseUTCDate(ref.timestamp)?.toLocaleDateString() ?? '' : ''}
                         </p>
                         </p>
                       </div>
                       </div>
                     ))}
                     ))}

+ 4 - 4
frontend/src/pages/QueuePage.tsx

@@ -50,7 +50,7 @@ import {
   Weight,
   Weight,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import { type TimeFormat, formatETA, formatDuration, formatRelativeTime } from '../utils/date';
+import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } 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';
@@ -501,7 +501,7 @@ function SortableQueueItem({
               <span className="flex items-center gap-1.5">
               <span className="flex items-center gap-1.5">
                 <Clock className="w-3.5 h-3.5" />
                 <Clock className="w-3.5 h-3.5" />
                 {item.scheduled_time
                 {item.scheduled_time
-                  ? (new Date(item.scheduled_time).getTime() - Date.now() < -60000
+                  ? ((parseUTCDate(item.scheduled_time)?.getTime() ?? 0) - Date.now() < -60000
                       ? t?.('queue.time.overdue') ?? 'Overdue'
                       ? t?.('queue.time.overdue') ?? 'Overdue'
                       : formatRelativeTime(item.scheduled_time, timeFormat, t))
                       : formatRelativeTime(item.scheduled_time, timeFormat, t))
                   : t?.('queue.time.asap') ?? 'ASAP'}
                   : t?.('queue.time.asap') ?? 'ASAP'}
@@ -869,7 +869,7 @@ export function QueuePage() {
     // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
     // Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
     const getScheduledTime = (item: PrintQueueItem): number => {
     const getScheduledTime = (item: PrintQueueItem): number => {
       if (!item.scheduled_time) return 0;
       if (!item.scheduled_time) return 0;
-      const time = new Date(item.scheduled_time).getTime();
+      const time = parseUTCDate(item.scheduled_time)?.getTime() ?? 0;
       // Placeholder dates (> 6 months out) are treated as ASAP
       // Placeholder dates (> 6 months out) are treated as ASAP
       const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
       const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
       return time > sixMonthsFromNow ? 0 : time;
       return time > sixMonthsFromNow ? 0 : time;
@@ -955,7 +955,7 @@ export function QueuePage() {
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
         cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
       } else {
       } else {
         // Default: by date - most recent first (desc) is the natural order
         // Default: by date - most recent first (desc) is the natural order
-        cmp = new Date(b.completed_at || b.created_at).getTime() - new Date(a.completed_at || a.created_at).getTime();
+        cmp = (parseUTCDate(b.completed_at || b.created_at)?.getTime() ?? 0) - (parseUTCDate(a.completed_at || a.created_at)?.getTime() ?? 0);
       }
       }
       return historySortAsc ? -cmp : cmp;
       return historySortAsc ? -cmp : cmp;
     });
     });

+ 2 - 1
frontend/src/utils/amsHelpers.ts

@@ -3,6 +3,7 @@
  * These functions handle color normalization, slot labeling, and tray ID calculations
  * These functions handle color normalization, slot labeling, and tray ID calculations
  * for AMS, AMS-HT, and external spool configurations.
  * for AMS, AMS-HT, and external spool configurations.
  */
  */
+import { parseUTCDate } from './date';
 
 
 /**
 /**
  * Normalize color format from various sources.
  * Normalize color format from various sources.
@@ -111,5 +112,5 @@ export function getMinDateTime(): string {
 export function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {
 export function isPlaceholderDate(scheduledTime: string | null | undefined): boolean {
   if (!scheduledTime) return false;
   if (!scheduledTime) return false;
   const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
   const sixMonthsFromNow = Date.now() + 180 * 24 * 60 * 60 * 1000;
-  return new Date(scheduledTime).getTime() > sixMonthsFromNow;
+  return (parseUTCDate(scheduledTime)?.getTime() ?? 0) > sixMonthsFromNow;
 }
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-oh6lPE3t.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-DFBcg8V9.js"></script>
+    <script type="module" crossorigin src="/assets/index-oh6lPE3t.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-1Ts9jjQl.css">
     <link rel="stylesheet" crossorigin href="/assets/index-1Ts9jjQl.css">
   </head>
   </head>
   <body>
   <body>

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