Explorar o código

fix(backup): interpret scheduled-backup HH:MM as local time, not UTC (#1602 follow-up)

  The Scheduled Local Backups time-of-day picker was interpreted as UTC by
  _calculate_next_run, so a UTC+3 user had to enter 18:00 to get a 21:00
  local backup. The UI labeled the field "UTC" but it was still surprising.

  Picker is now interpreted in the container's local timezone, resolved
  from the TZ env var via zoneinfo.ZoneInfo (same source the Support page's
  environment.timezone shows). UTC fallback when TZ is unset or
  unrecognised. The /local-backup/status endpoint exposes the resolved
  zone, and the UI renders it next to the field via a new
  backup.localTimeHint i18n key with real translations in all 10
  non-English locales.

  One-time behaviour change for users who entered a UTC time as a
  workaround: the first scheduled cycle after upgrade will run at their
  local TZ offset earlier than expected. Re-enter the time as local once
  and it is correct from then on. No migration is shipped; migrating
  around a DST boundary would be ambiguous.
maziggy hai 3 meses
pai
achega
a1cb5d5b4d

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 0
CHANGELOG.md


+ 6 - 0
backend/app/api/routes/local_backup.py

@@ -20,6 +20,8 @@ async def get_status(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
 ):
     """Get local backup scheduler status and configuration."""
+    from backend.app.services.local_backup import _local_zone
+
     settings = await local_backup_service._load_settings()
     status = local_backup_service.get_status()
     return {
@@ -30,6 +32,10 @@ async def get_status(
         "retention": settings["retention"],
         "path": settings["path"],
         "default_path": str(local_backup_service._resolve_backup_dir("")),
+        # IANA zone name the HH:MM picker is interpreted in (TZ env, UTC fallback).
+        # Frontend renders this next to the time field so users see the same
+        # zone the backend will use. #1602 follow-up.
+        "timezone": str(_local_zone()),
     }
 
 

+ 38 - 10
backend/app/services/local_backup.py

@@ -6,8 +6,10 @@ on a configurable schedule with retention management.
 
 import asyncio
 import logging
+import os
 from datetime import datetime, timedelta, timezone
 from pathlib import Path
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
 from sqlalchemy import select
 
@@ -17,6 +19,24 @@ from backend.app.models.settings import Settings
 
 logger = logging.getLogger(__name__)
 
+
+def _local_zone() -> ZoneInfo:
+    """Resolve the local timezone for scheduled-backup HH:MM interpretation.
+
+    Uses the container's ``TZ`` env var (the same value the support package
+    surfaces); falls back to UTC when unset or unrecognised so a missing TZ
+    keeps the legacy behaviour rather than crashing. See #1602 follow-up.
+    """
+    tz_name = os.environ.get("TZ", "").strip()
+    if not tz_name:
+        return ZoneInfo("UTC")
+    try:
+        return ZoneInfo(tz_name)
+    except ZoneInfoNotFoundError:
+        logger.warning("Unrecognised TZ env value %r, scheduling in UTC", tz_name)
+        return ZoneInfo("UTC")
+
+
 SCHEDULE_INTERVALS = {
     "hourly": 3600,
     "daily": 86400,
@@ -122,14 +142,16 @@ class LocalBackupService:
     def _calculate_next_run(self, schedule_type: str, time_str: str = "03:00") -> datetime:
         """Calculate the next scheduled run time.
 
-        For hourly: next full hour.
-        For daily/weekly: next occurrence of the configured time (HH:MM).
+        For hourly: next full hour (timezone-agnostic).
+        For daily/weekly: next occurrence of the configured HH:MM, interpreted
+        in the container's local timezone (TZ env var, UTC fallback). Returns
+        a UTC-aware datetime for storage / comparison against ``now``.
         """
-        now = datetime.now(timezone.utc)
+        now_utc = datetime.now(timezone.utc)
 
         if schedule_type == "hourly":
             # Next full hour
-            next_run = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
+            next_run = now_utc.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
             return next_run
 
         # Parse HH:MM time
@@ -140,15 +162,21 @@ class LocalBackupService:
         except (ValueError, IndexError):
             hour, minute = 3, 0
 
-        # Next occurrence of this time today or tomorrow
-        next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
-        if next_run <= now:
-            next_run += timedelta(days=1)
+        local_tz = _local_zone()
+        now_local = now_utc.astimezone(local_tz)
+        # Next occurrence of HH:MM local time, today or tomorrow.
+        # ``fold=0`` resolves the ambiguous wall-clock window at DST fall-back
+        # to the earlier instance (consistent with cron's behaviour). On the
+        # spring-forward gap the synthesized local time will normalise to the
+        # next valid instant when converted to UTC.
+        next_local = now_local.replace(hour=hour, minute=minute, second=0, microsecond=0, fold=0)
+        if next_local <= now_local:
+            next_local += timedelta(days=1)
 
         if schedule_type == "weekly":
-            next_run += timedelta(weeks=1)
+            next_local += timedelta(weeks=1)
 
-        return next_run
+        return next_local.astimezone(timezone.utc)
 
     def _resolve_backup_dir(self, path_setting: str) -> Path:
         """Resolve the backup output directory from settings."""

+ 89 - 16
backend/tests/unit/test_local_backup.py

@@ -12,9 +12,15 @@ from backend.app.services.local_backup import LocalBackupService
 
 
 class TestCalculateNextRun:
-    """Tests for _calculate_next_run scheduling logic."""
+    """Tests for _calculate_next_run scheduling logic.
 
-    def test_hourly_returns_next_full_hour(self):
+    The HH:MM picker is interpreted in the container's local timezone (TZ env
+    var, UTC fallback). Each test pins TZ so the assertions don't depend on
+    the test runner's environment.
+    """
+
+    def test_hourly_returns_next_full_hour(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 14, 30, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
@@ -24,47 +30,48 @@ class TestCalculateNextRun:
         assert result.hour == 15
         assert result.minute == 0
 
-    def test_daily_before_target_time_schedules_today(self):
+    def test_daily_before_target_time_schedules_today_utc(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
             mock_dt.now.return_value = now
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             result = service._calculate_next_run("daily", "03:00")
-        assert result.day == 12
-        assert result.hour == 3
+        assert result == datetime(2026, 4, 12, 3, 0, 0, tzinfo=timezone.utc)
 
-    def test_daily_after_target_time_schedules_tomorrow(self):
+    def test_daily_after_target_time_schedules_tomorrow_utc(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
             mock_dt.now.return_value = now
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             result = service._calculate_next_run("daily", "03:00")
-        assert result.day == 13
-        assert result.hour == 3
+        assert result == datetime(2026, 4, 13, 3, 0, 0, tzinfo=timezone.utc)
 
-    def test_weekly_adds_full_week(self):
+    def test_weekly_adds_full_week_utc(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
             mock_dt.now.return_value = now
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             result = service._calculate_next_run("weekly", "03:00")
-        expected = datetime(2026, 4, 19, 3, 0, 0, tzinfo=timezone.utc)
-        assert result == expected
+        assert result == datetime(2026, 4, 19, 3, 0, 0, tzinfo=timezone.utc)
 
-    def test_weekly_after_target_time_adds_full_week_from_tomorrow(self):
+    def test_weekly_after_target_time_adds_full_week_from_tomorrow_utc(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 4, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
             mock_dt.now.return_value = now
             mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
             result = service._calculate_next_run("weekly", "03:00")
-        expected = datetime(2026, 4, 20, 3, 0, 0, tzinfo=timezone.utc)
-        assert result == expected
+        assert result == datetime(2026, 4, 20, 3, 0, 0, tzinfo=timezone.utc)
 
-    def test_invalid_time_defaults_to_0300(self):
+    def test_invalid_time_defaults_to_0300(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
@@ -74,7 +81,8 @@ class TestCalculateNextRun:
         assert result.hour == 3
         assert result.minute == 0
 
-    def test_unknown_schedule_type_defaults_to_daily(self):
+    def test_unknown_schedule_type_defaults_to_daily(self, monkeypatch):
+        monkeypatch.setenv("TZ", "UTC")
         service = LocalBackupService()
         now = datetime(2026, 4, 12, 2, 0, 0, tzinfo=timezone.utc)
         with patch("backend.app.services.local_backup.datetime") as mock_dt:
@@ -84,6 +92,71 @@ class TestCalculateNextRun:
         # Should fall through to daily behavior (time-based)
         assert result.hour == 3
 
+    def test_daily_berlin_local_time_converts_to_utc(self, monkeypatch):
+        """User in Europe/Berlin entering 21:00 should run at 19:00 UTC (CEST/UTC+2)."""
+        monkeypatch.setenv("TZ", "Europe/Berlin")
+        service = LocalBackupService()
+        # Mid-June: Europe/Berlin is CEST (+02:00)
+        now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc)  # 12:00 Berlin
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("daily", "21:00")
+        # 21:00 Berlin (CEST, +02:00) on 2026-06-15 == 19:00 UTC same day
+        assert result == datetime(2026, 6, 15, 19, 0, 0, tzinfo=timezone.utc)
+
+    def test_daily_istanbul_local_time_converts_to_utc(self, monkeypatch):
+        """The #1602 reporter: UTC+3 user entering 21:00 should run at 18:00 UTC."""
+        monkeypatch.setenv("TZ", "Europe/Istanbul")
+        service = LocalBackupService()
+        now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc)  # 13:00 Istanbul
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("daily", "21:00")
+        assert result == datetime(2026, 6, 15, 18, 0, 0, tzinfo=timezone.utc)
+
+    def test_no_tz_env_falls_back_to_utc(self, monkeypatch):
+        monkeypatch.delenv("TZ", raising=False)
+        service = LocalBackupService()
+        now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("daily", "21:00")
+        # No TZ → behaves as UTC: 21:00 today is in the future of 10:00, so today
+        assert result == datetime(2026, 6, 15, 21, 0, 0, tzinfo=timezone.utc)
+
+    def test_unrecognised_tz_falls_back_to_utc(self, monkeypatch):
+        monkeypatch.setenv("TZ", "Not/A_Real_Zone")
+        service = LocalBackupService()
+        now = datetime(2026, 6, 15, 10, 0, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            result = service._calculate_next_run("daily", "21:00")
+        assert result == datetime(2026, 6, 15, 21, 0, 0, tzinfo=timezone.utc)
+
+    def test_dst_spring_forward_gap_does_not_crash(self, monkeypatch):
+        """Europe/Berlin spring-forward 2026-03-29 jumps 02:00 → 03:00 local;
+        02:30 wall-clock does not exist. ``replace(hour=2, minute=30)`` should
+        still normalise to a valid UTC instant via astimezone, not raise.
+        """
+        monkeypatch.setenv("TZ", "Europe/Berlin")
+        service = LocalBackupService()
+        # 2026-03-29 00:30 UTC == 01:30 Berlin (CET, just before the gap)
+        now = datetime(2026, 3, 29, 0, 30, 0, tzinfo=timezone.utc)
+        with patch("backend.app.services.local_backup.datetime") as mock_dt:
+            mock_dt.now.return_value = now
+            mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
+            # 02:30 local is in the non-existent gap on that day.
+            result = service._calculate_next_run("daily", "02:30")
+        # Result must be a UTC-aware datetime — exact value depends on
+        # zoneinfo's gap normalisation; we just guarantee no crash and that
+        # the run is in the future of ``now``.
+        assert result.tzinfo == timezone.utc
+        assert result > now
+
 
 class TestPruneBackups:
     """Tests for backup retention pruning."""

+ 1 - 0
frontend/src/__tests__/components/GitHubBackupSettings.scheduled.test.tsx

@@ -21,6 +21,7 @@ const mockLocalBackupStatus = {
   last_status: null,
   last_message: null,
   next_run: '2026-04-13T03:00:00+00:00',
+  timezone: 'Europe/Berlin',
 };
 
 const mockLocalBackups = [

+ 1 - 0
frontend/src/api/client.ts

@@ -2279,6 +2279,7 @@ export interface LocalBackupStatus {
   last_status: string | null;
   last_message: string | null;
   next_run: string | null;
+  timezone: string;
 }
 
 export interface LocalBackupFile {

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

@@ -1194,7 +1194,9 @@ export function GitHubBackupSettings() {
                           refetchLocalStatus();
                         }}
                       />
-                      <p className="text-xs text-bambu-gray-light mt-1">{t('backup.utc')}</p>
+                      <p className="text-xs text-bambu-gray-light mt-1">
+                        {t('backup.localTimeHint', { tz: localBackupStatus?.timezone || 'UTC' })}
+                      </p>
                     </div>
                   )}
                   <div>

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -4119,7 +4119,7 @@ export default {
     scheduledBackupFailed: 'Backup fehlgeschlagen',
     nextBackup: 'Nächstes Backup',
     backupSize: 'Größe',
-    utc: 'UTC',
+    localTimeHint: 'Ortszeit ({{tz}})',
     defaultPathLabel: 'Standard:',
 
     // Category labels

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -4131,7 +4131,7 @@ export default {
     scheduledBackupFailed: 'Backup failed',
     nextBackup: 'Next backup',
     backupSize: 'Size',
-    utc: 'UTC',
+    localTimeHint: 'Local time ({{tz}})',
     defaultPathLabel: 'Default:',
 
     // Category labels

+ 1 - 1
frontend/src/i18n/locales/es.ts

@@ -4127,7 +4127,7 @@ export default {
     scheduledBackupFailed: 'Error en la copia de seguridad',
     nextBackup: 'Próxima copia de seguridad',
     backupSize: 'Tamaño',
-    utc: 'UTC',
+    localTimeHint: 'Hora local ({{tz}})',
     defaultPathLabel: 'Predeterminada:',
 
     // Category labels

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -4108,7 +4108,7 @@ export default {
     scheduledBackupFailed: 'Échec de la sauvegarde',
     nextBackup: 'Prochaine sauvegarde',
     backupSize: 'Taille',
-    utc: 'UTC',
+    localTimeHint: 'Heure locale ({{tz}})',
     defaultPathLabel: 'Par défaut :',
 
     // Category labels

+ 1 - 1
frontend/src/i18n/locales/it.ts

@@ -4107,7 +4107,7 @@ export default {
     scheduledBackupFailed: 'Backup fallito',
     nextBackup: 'Prossimo backup',
     backupSize: 'Dimensione',
-    utc: 'UTC',
+    localTimeHint: 'Ora locale ({{tz}})',
     defaultPathLabel: 'Predefinito:',
 
     // Category labels

+ 1 - 1
frontend/src/i18n/locales/ja.ts

@@ -4119,7 +4119,7 @@ export default {
     scheduledBackupFailed: 'バックアップに失敗',
     nextBackup: '次回バックアップ',
     backupSize: 'サイズ',
-    utc: 'UTC',
+    localTimeHint: '現地時刻 ({{tz}})',
     defaultPathLabel: 'デフォルト:',
 
     // Category labels

+ 1 - 1
frontend/src/i18n/locales/ko.ts

@@ -3878,7 +3878,7 @@ export default {
     scheduledBackupFailed: '백업 실패',
     nextBackup: '다음 백업',
     backupSize: '크기',
-    utc: 'UTC',
+    localTimeHint: '현지 시간 ({{tz}})',
     defaultPathLabel: '기본값:',
     categories: {
       settings: '설정',

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -4107,7 +4107,7 @@ export default {
     scheduledBackupFailed: 'Falha no backup',
     nextBackup: 'Próximo backup',
     backupSize: 'Tamanho',
-    utc: 'UTC',
+    localTimeHint: 'Horário local ({{tz}})',
     defaultPathLabel: 'Padrão:',
 
     // Category labels

+ 1 - 1
frontend/src/i18n/locales/tr.ts

@@ -4094,7 +4094,7 @@ export default {
     scheduledBackupFailed: 'Yedekleme başarısız',
     nextBackup: 'Sonraki yedek',
     backupSize: 'Boyut',
-    utc: 'UTC',
+    localTimeHint: 'Yerel saat ({{tz}})',
     defaultPathLabel: 'Varsayılan:',
 
     categories: {

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -4107,7 +4107,7 @@ export default {
     scheduledBackupFailed: '备份失败',
     nextBackup: '下次备份',
     backupSize: '大小',
-    utc: 'UTC',
+    localTimeHint: '本地时间 ({{tz}})',
     defaultPathLabel: '默认:',
 
     // Category labels

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -4107,7 +4107,7 @@ export default {
     scheduledBackupFailed: '備份失敗',
     nextBackup: '下次備份',
     backupSize: '大小',
-    utc: 'UTC',
+    localTimeHint: '本地時間 ({{tz}})',
     defaultPathLabel: '預設:',
 
     // Category labels

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-Cc-CPvTp.js


+ 1 - 1
static/index.html

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

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio