From 89afd39f0ade49eff27ba8eed5a2662bf9201c54 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 16:03:10 +0000 Subject: [PATCH] feat(core): schedule windows (time-of-day backup gating) Add an optional schedule window that pauses backups outside a user-configured local-time range and resumes automatically once the clock re-enters it (DESIGN s17, "only sync 23:00-06:00"). Core: a new `ScheduleConfig` with a pure, FakeClock-deterministic `allows()` predicate and a `PauseReason::Schedule` gate in the orchestrator, placed after the battery gate. Like the pacer's "midnight Pacific" boundary, driven-core stays free of a timezone database: local time is derived from a fixed `utc_offset_minutes` the app captures from the OS, with the same bounded-DST caveat. Same-day, midnight-wrapping, whole-day, day-of-week, and UTC-offset cases are all unit-tested, plus two orchestrator gate tests. Backend: thread the schedule through the SPEC s22 `global` settings group (DTO, patch, snake_case storage with serde(default) so pre-V2 blobs still load) into `OrchestratorConfig`, clamping defensively. Tray gains a paused-on-schedule tooltip + classification. UI: a new Settings "schedule window" section (enable, from/to times, day-of-week toggles) wired through the settings store, with TS types, i18n, and a component test. The UTC offset is captured fresh from the browser on each save. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WvXMHHbYGddVPpmQR2XmK1 --- crates/driven-core/src/orchestrator.rs | 99 ++++++++- crates/driven-core/src/types.rs | 212 +++++++++++++++++++ src-tauri/locales/en-US.yml | 1 + src-tauri/src/commands/dtos.rs | 25 +++ src-tauri/src/commands/settings.rs | 104 ++++++++- src-tauri/src/tray.rs | 7 +- ui/src/__tests__/settings-components.test.ts | 58 ++++- ui/src/__tests__/settings-stores.test.ts | 7 + ui/src/ipc/types.ts | 16 ++ ui/src/locales/en-US.json | 18 +- ui/src/views/Settings.vue | 122 +++++++++++ 11 files changed, 662 insertions(+), 7 deletions(-) diff --git a/crates/driven-core/src/orchestrator.rs b/crates/driven-core/src/orchestrator.rs index ea0af78f..82bc5002 100644 --- a/crates/driven-core/src/orchestrator.rs +++ b/crates/driven-core/src/orchestrator.rs @@ -61,7 +61,7 @@ use crate::state::{ActivityLevel, NewActivity, SourceRow, StateRepo}; use crate::time::Clock; use crate::types::{ AccountId, ExecProgress, OrchestratorEvent, OrchestratorState, PauseReason, PowerEvent, - RelativePath, ScanMode, UnixMs, + RelativePath, ScanMode, ScheduleConfig, UnixMs, }; use crate::watcher::ScanTickRequest; @@ -172,6 +172,12 @@ pub struct OrchestratorConfig { /// `windows` settings key, wired in by the app shell (M5/M6); the field is /// here now so the orchestrator honours it. pub vss_mode: VssMode, + /// Schedule window (V2 schedule windows, DESIGN s17): when + /// [`enabled`](ScheduleConfig::enabled), the gate pauses with + /// [`PauseReason::Schedule`] outside the allowed local-time window and + /// resumes automatically once the clock re-enters it. The + /// [`Default`](ScheduleConfig::default) is disabled (V1 behaviour). + pub schedule: ScheduleConfig, } impl Default for OrchestratorConfig { @@ -187,6 +193,7 @@ impl Default for OrchestratorConfig { bandwidth_cap_mbps: None, pacer_ceilings: PacerCeilings::default(), vss_mode: VssMode::Auto, + schedule: ScheduleConfig::default(), } } } @@ -722,6 +729,15 @@ impl SyncOrchestrator { return GateDecision::Pause(PauseReason::Battery); } + // Schedule window (V2 schedule windows, DESIGN s17): pause outside the + // user's allowed local-time window. Reads the injected Clock so the + // decision is deterministic; the gate re-opens on a later cycle once + // the clock re-enters the window (no manual resume needed). A disabled + // schedule always allows, so this is inert under the V1 default. + if !cfg.schedule.allows(self.clock.now_ms()) { + return GateDecision::Pause(PauseReason::Schedule); + } + // Drive circuit breaker (DESIGN s5.8.3): if Drive's breaker is open, // back off until its half-open probe time rather than hammer a known- // down dependency. @@ -2428,6 +2444,87 @@ mod tests { ); } + #[tokio::test] + async fn schedule_gate_pauses_outside_window() { + // AC + online + not metered, but the clock (FakeClock starts at epoch + // 1970-01-01 00:00 UTC) is outside a 09:00-17:00 window => Paused{Schedule}. + let account = AccountId::new_v4(); + let dir = tempfile::tempdir().unwrap(); + let src = source_in(account, dir.path()); + let exec = Arc::new(RecordingExecutor::default()); + let cfg = OrchestratorConfig { + schedule: crate::types::ScheduleConfig { + enabled: true, + start_minute: 9 * 60, + end_minute: 17 * 60, + days: [true; 7], + utc_offset_minutes: 0, + }, + ..OrchestratorConfig::default() + }; + let (orch, _clock) = build( + account, + vec![src], + exec.clone(), + power_on_ac(), + Arc::new(FakeNet::online()), + cfg, + ); + + orch.run_cycle(TickSource::Scheduled).await.unwrap(); + + assert_eq!( + orch.state().await, + OrchestratorState::Paused { + reason: PauseReason::Schedule + } + ); + assert_eq!( + exec.executes.load(Ordering::SeqCst), + 0, + "outside the schedule window no plan executes" + ); + } + + #[tokio::test] + async fn schedule_gate_opens_inside_window() { + // Same window, but advance the clock to 09:00 UTC so the gate opens and + // the cycle proceeds past the schedule gate (no Schedule pause). + let account = AccountId::new_v4(); + let dir = tempfile::tempdir().unwrap(); + let src = source_in(account, dir.path()); + let exec = Arc::new(RecordingExecutor::default()); + let cfg = OrchestratorConfig { + schedule: crate::types::ScheduleConfig { + enabled: true, + start_minute: 9 * 60, + end_minute: 17 * 60, + days: [true; 7], + utc_offset_minutes: 0, + }, + ..OrchestratorConfig::default() + }; + let (orch, clock) = build( + account, + vec![src], + exec.clone(), + power_on_ac(), + Arc::new(FakeNet::online()), + cfg, + ); + clock.advance(std::time::Duration::from_secs(9 * 3600)); // -> 09:00 UTC + + orch.run_cycle(TickSource::Scheduled).await.unwrap(); + + assert_ne!( + orch.state().await, + OrchestratorState::Paused { + reason: PauseReason::Schedule + }, + "inside the window the schedule gate must not pause" + ); + } + #[tokio::test] async fn ac_resumes_after_battery_pause() { // Power gate: pause on battery, resume on AC (the two-cycle path). diff --git a/crates/driven-core/src/types.rs b/crates/driven-core/src/types.rs index 77bf7b52..1484361e 100644 --- a/crates/driven-core/src/types.rs +++ b/crates/driven-core/src/types.rs @@ -326,6 +326,109 @@ pub enum PauseReason { /// DNS broken; SPEC s24 `net.dns_failed`). Kept distinct from [`Offline`] /// per CODEX_NOTES P2-9 (M4). DnsFailed, + /// Outside the user's configured schedule window (V2 schedule windows, + /// DESIGN s17). The orchestrator resumes automatically once the local + /// clock re-enters the allowed window - no manual action required. + Schedule, +} + +// ----------------------------------------------------------------------------- +// ScheduleConfig (V2 schedule windows - DESIGN s17) +// ----------------------------------------------------------------------------- + +/// A time-of-day + day-of-week window during which sync is allowed (V2 +/// schedule windows, DESIGN s17 "only sync 23:00-06:00"). +/// +/// The window is expressed in the user's LOCAL wall-clock time. Like the +/// pacer's "midnight Pacific" quota boundary (see [`crate::pacer`]), +/// `driven-core` stays free of a timezone database: local time is derived +/// from a fixed [`Self::utc_offset_minutes`] the app layer captures from the +/// OS / browser. The bounded consequence is the same as the pacer's - across +/// a DST transition the window shifts by up to an hour until the app +/// re-reads the offset. This is deliberate and documented (DESIGN s17). +/// +/// The predicate is a pure function of the injected [`Clock`](crate::time::Clock) +/// reading, so the orchestrator gate is deterministic under `FakeClock`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScheduleConfig { + /// When `false` the schedule never gates (sync runs at any time). This is + /// the V1 behaviour and the [`Default`]. + pub enabled: bool, + /// Minutes after local midnight the allowed window opens, `0..=1439`. + pub start_minute: u16, + /// Minutes after local midnight the allowed window closes, `0..=1439`. + /// + /// - `end > start`: a same-day window `[start, end)`. + /// - `end < start`: the window wraps past midnight (active `[start, 1440)` + /// and `[0, end)`). + /// - `end == start`: the whole day is allowed (only [`Self::days`] gates). + pub end_minute: u16, + /// Which local days the window is active on, indexed `0 = Sunday ..= + /// 6 = Saturday` to match JavaScript's `Date.getDay()`. The window is + /// evaluated against the CURRENT local day, so a window that wraps past + /// midnight (e.g. 23:00-06:00) needs both the evening day and the + /// following morning's day enabled for the whole window to be allowed. + pub days: [bool; 7], + /// Minutes to ADD to UTC to reach the user's local wall-clock time + /// (e.g. `-480` for PST = UTC-8). The app layer sets this from the OS; + /// the browser value is `-new Date().getTimezoneOffset()`. + pub utc_offset_minutes: i16, +} + +impl Default for ScheduleConfig { + /// Disabled: sync runs at any time (V1 behaviour). The window fields are + /// inert while `enabled` is false. + fn default() -> Self { + Self { + enabled: false, + start_minute: 0, + end_minute: 0, + days: [true; 7], + utc_offset_minutes: 0, + } + } +} + +impl ScheduleConfig { + /// Milliseconds per minute / minutes per day, for the local-time maths. + const MS_PER_MIN: i64 = 60_000; + const MINS_PER_DAY: i64 = 1_440; + + /// True if sync is allowed at the wall-clock instant `now_ms`. + /// + /// A disabled schedule always allows. Otherwise the UTC instant is shifted + /// into local wall time by [`Self::utc_offset_minutes`], reduced to a + /// local day-of-week + minute-of-day, and tested against the window. Uses + /// Euclidean division/remainder so a negative (pre-epoch) or + /// backwards-jumped clock reading still yields an in-range day/minute + /// rather than a panic (DESIGN s18.7 - the clock may move backwards). + pub fn allows(&self, now_ms: UnixMs) -> bool { + if !self.enabled { + return true; + } + let local_ms = now_ms.saturating_add((self.utc_offset_minutes as i64) * Self::MS_PER_MIN); + let total_min = local_ms.div_euclid(Self::MS_PER_MIN); + let min_of_day = total_min.rem_euclid(Self::MINS_PER_DAY) as u16; + // Days since the Unix epoch in local time. 1970-01-01 was a Thursday, + // which is `getDay() == 4`, so offset the day count by 4 before the + // mod-7 to land on the Sunday-indexed weekday. + let day_index = total_min.div_euclid(Self::MINS_PER_DAY); + let dow = (day_index + 4).rem_euclid(7) as usize; + if !self.days[dow] { + return false; + } + let (s, e) = (self.start_minute, self.end_minute); + if s == e { + // Whole day allowed; only the day-of-week gates. + return true; + } + if s < e { + min_of_day >= s && min_of_day < e + } else { + // Wraps past midnight. + min_of_day >= s || min_of_day < e + } + } } // ----------------------------------------------------------------------------- @@ -1209,4 +1312,113 @@ mod tests { let rp: RelativePath = std::path::Path::new("a/b.txt").try_into().unwrap(); assert_eq!(rp.as_str(), "a/b.txt"); } + + // --- ScheduleConfig (V2 schedule windows) ------------------------------- + + /// Monday 2024-01-01 00:00:00 UTC, in epoch ms. The dow formula resolves + /// this to `getDay() == 1` (Monday); used as the anchor for the cases + /// below (offsets in minutes/days are added on top). + const MON_2024_01_01_UTC_MS: UnixMs = 1_704_067_200_000; + const MIN_MS: UnixMs = 60_000; + const DAY_MS: UnixMs = 1_440 * MIN_MS; + + fn all_days() -> [bool; 7] { + [true; 7] + } + + #[test] + fn schedule_disabled_always_allows() { + let s = ScheduleConfig::default(); + assert!(!s.enabled); + assert!(s.allows(MON_2024_01_01_UTC_MS)); + assert!(s.allows(0)); + assert!(s.allows(-1)); // pre-epoch must not panic + } + + #[test] + fn schedule_same_day_window_half_open() { + // 09:00-17:00 every day. + let s = ScheduleConfig { + enabled: true, + start_minute: 9 * 60, + end_minute: 17 * 60, + days: all_days(), + utc_offset_minutes: 0, + }; + let at = |min: i64| s.allows(MON_2024_01_01_UTC_MS + min * MIN_MS); + assert!(!at(0)); // 00:00 - before + assert!(!at(8 * 60 + 59)); // 08:59 - before + assert!(at(9 * 60)); // 09:00 - open (inclusive) + assert!(at(16 * 60 + 59)); // 16:59 - inside + assert!(!at(17 * 60)); // 17:00 - close (exclusive) + assert!(!at(23 * 60)); // 23:00 - after + } + + #[test] + fn schedule_wrap_past_midnight() { + // 23:00-06:00 every day. + let s = ScheduleConfig { + enabled: true, + start_minute: 23 * 60, + end_minute: 6 * 60, + days: all_days(), + utc_offset_minutes: 0, + }; + let at = |min: i64| s.allows(MON_2024_01_01_UTC_MS + min * MIN_MS); + assert!(at(23 * 60)); // 23:00 - open + assert!(at(23 * 60 + 30)); // 23:30 - evening tail + assert!(at(0)); // 00:00 - past midnight + assert!(at(5 * 60 + 59)); // 05:59 - morning + assert!(!at(6 * 60)); // 06:00 - close (exclusive) + assert!(!at(12 * 60)); // noon - outside + } + + #[test] + fn schedule_equal_bounds_is_whole_day() { + // start == end => only the day-of-week gates. + let s = ScheduleConfig { + enabled: true, + start_minute: 0, + end_minute: 0, + days: all_days(), + utc_offset_minutes: 0, + }; + for h in [0, 6, 12, 18, 23] { + assert!(s.allows(MON_2024_01_01_UTC_MS + h * 60 * MIN_MS)); + } + } + + #[test] + fn schedule_day_of_week_gates() { + // Whole-day window, but only Monday (index 1) enabled. + let mut days = [false; 7]; + days[1] = true; // Monday + let s = ScheduleConfig { + enabled: true, + start_minute: 0, + end_minute: 0, + days, + utc_offset_minutes: 0, + }; + assert!(s.allows(MON_2024_01_01_UTC_MS)); // Monday + assert!(!s.allows(MON_2024_01_01_UTC_MS + DAY_MS)); // Tuesday + assert!(!s.allows(MON_2024_01_01_UTC_MS - DAY_MS)); // Sunday + assert!(s.allows(MON_2024_01_01_UTC_MS + 7 * DAY_MS)); // next Monday + } + + #[test] + fn schedule_utc_offset_shifts_local_time() { + // 00:00-01:00 LOCAL, every day, at UTC+1. 00:00 UTC == 01:00 local, + // which is outside [00:00, 01:00); one hour earlier (23:00 UTC) == + // 00:00 local, which is inside. + let s = ScheduleConfig { + enabled: true, + start_minute: 0, + end_minute: 60, + days: all_days(), + utc_offset_minutes: 60, + }; + assert!(!s.allows(MON_2024_01_01_UTC_MS)); // 01:00 local + assert!(s.allows(MON_2024_01_01_UTC_MS - 60 * MIN_MS)); // 00:00 local + } } diff --git a/src-tauri/locales/en-US.yml b/src-tauri/locales/en-US.yml index e1973bf0..bbfc0ab2 100644 --- a/src-tauri/locales/en-US.yml +++ b/src-tauri/locales/en-US.yml @@ -20,6 +20,7 @@ tray: paused_manual: "Driven - paused" paused_battery: "Driven - paused on battery power" paused_metered: "Driven - paused on metered network" + paused_schedule: "Driven - paused outside the scheduled window" offline: "Connected, no Internet" no_internet: "Connected, no Internet" captive_portal: "Captive portal - click to sign in" diff --git a/src-tauri/src/commands/dtos.rs b/src-tauri/src/commands/dtos.rs index 4f65e465..2e7ad129 100644 --- a/src-tauri/src/commands/dtos.rs +++ b/src-tauri/src/commands/dtos.rs @@ -374,6 +374,29 @@ pub struct GlobalSettings { pub io_priority: String, /// `tracing` log level. pub log_level: String, + /// V2 schedule window (DESIGN s17): when enabled, sync is gated to the + /// configured local-time window. + pub schedule: ScheduleSettings, +} + +/// V2 schedule-window settings (DESIGN s17). Mirrors +/// [`driven_core::types::ScheduleConfig`]; the times are local wall-clock +/// minutes and `utc_offset_minutes` is `-new Date().getTimezoneOffset()`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScheduleSettings { + /// When `false`, sync runs at any time (the default / V1 behaviour). + pub enabled: bool, + /// Minutes after local midnight the allowed window opens, `0..=1439`. + pub start_minute: u32, + /// Minutes after local midnight the allowed window closes, `0..=1439`. + /// `end < start` wraps past midnight; `end == start` allows the whole day. + pub end_minute: u32, + /// Seven booleans, `0 = Sunday ..= 6 = Saturday`, marking the local days + /// the window is active on. + pub days: Vec, + /// Minutes to add to UTC to reach local time (e.g. `-480` for PST). + pub utc_offset_minutes: i32, } /// SPEC s22 `telemetry` settings. @@ -459,6 +482,8 @@ pub struct GlobalSettingsPatch { pub io_priority: Option, /// See [`GlobalSettings::log_level`]. pub log_level: Option, + /// See [`GlobalSettings::schedule`]. Present = replace the whole schedule. + pub schedule: Option, } /// Partial SPEC s22 `telemetry` settings. diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index c285f744..41e76018 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -39,8 +39,8 @@ use driven_vss::VssMode; use crate::app_state::AppState; use crate::commands::dtos::{ - GlobalSettings, ReleaseDto, SettingsDto, SettingsPatch, TelemetrySettings, UiSettings, - UpdateInfo, UpdaterSettings, WindowsSettings, + GlobalSettings, ReleaseDto, ScheduleSettings, SettingsDto, SettingsPatch, TelemetrySettings, + UiSettings, UpdateInfo, UpdaterSettings, WindowsSettings, }; use crate::commands::{ atomic_write, validate_writable_dest, CommandError, CommandResult, DialogToken, @@ -261,6 +261,14 @@ pub async fn update_settings( } cur.log_level = v; } + if let Some(v) = g.schedule { + // Schedule window (DESIGN s17): bounds are local minutes 0..=1439 + // (an end of 0 means midnight, which wraps a same-evening start). + check_range("schedule.start_minute", v.start_minute, 0, 1439)?; + check_range("schedule.end_minute", v.end_minute, 0, 1439)?; + cur.schedule = v; + orchestrator_affecting = true; + } store_group(repo, KEY_GLOBAL, &storage::Global::from(cur)).await?; } @@ -504,9 +512,57 @@ mod storage { use serde::{Deserialize, Serialize}; use crate::commands::dtos::{ - GlobalSettings, TelemetrySettings, UiSettings, UpdaterSettings, WindowsSettings, + GlobalSettings, ScheduleSettings, TelemetrySettings, UiSettings, UpdaterSettings, + WindowsSettings, }; + /// `snake_case` on-disk form of the V2 schedule window (DESIGN s17). + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct Schedule { + pub enabled: bool, + pub start_minute: u32, + pub end_minute: u32, + pub days: Vec, + pub utc_offset_minutes: i32, + } + + impl Default for Schedule { + fn default() -> Self { + // Disabled, all-day/every-day (matches `default_schedule`). + Schedule { + enabled: false, + start_minute: 0, + end_minute: 0, + days: vec![true; 7], + utc_offset_minutes: 0, + } + } + } + + impl From for ScheduleSettings { + fn from(s: Schedule) -> Self { + ScheduleSettings { + enabled: s.enabled, + start_minute: s.start_minute, + end_minute: s.end_minute, + days: s.days, + utc_offset_minutes: s.utc_offset_minutes, + } + } + } + + impl From for Schedule { + fn from(d: ScheduleSettings) -> Self { + Schedule { + enabled: d.enabled, + start_minute: d.start_minute, + end_minute: d.end_minute, + days: d.days, + utc_offset_minutes: d.utc_offset_minutes, + } + } + } + /// `snake_case` on-disk form of the SPEC s22 `global` group. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Global { @@ -519,6 +575,11 @@ mod storage { pub deep_verify_interval_secs: u32, pub io_priority: String, pub log_level: String, + // Added in V2 (schedule windows). `serde(default)` so a `global` blob + // persisted before this field still deserialises (the disabled + // default = V1 behaviour). + #[serde(default)] + pub schedule: Schedule, } impl From for GlobalSettings { @@ -533,6 +594,7 @@ mod storage { deep_verify_interval_secs: s.deep_verify_interval_secs, io_priority: s.io_priority, log_level: s.log_level, + schedule: s.schedule.into(), } } } @@ -549,6 +611,7 @@ mod storage { deep_verify_interval_secs: d.deep_verify_interval_secs, io_priority: d.io_priority, log_level: d.log_level, + schedule: d.schedule.into(), } } } @@ -761,9 +824,30 @@ pub async fn load_orchestrator_config(state: &dyn StateRepo) -> CommandResult driven_core::types::ScheduleConfig { + let mut days = [true; 7]; + for (i, slot) in days.iter_mut().enumerate() { + if let Some(d) = s.days.get(i) { + *slot = *d; + } + } + driven_core::types::ScheduleConfig { + enabled: s.enabled, + start_minute: s.start_minute.min(1439) as u16, + end_minute: s.end_minute.min(1439) as u16, + days, + utc_offset_minutes: s.utc_offset_minutes.clamp(-1440, 1440) as i16, + } +} + // --------------------------------------------------------------------------- // export_diagnostic_bundle (SPEC s11.6, s18) // --------------------------------------------------------------------------- @@ -1890,6 +1974,20 @@ fn default_global() -> GlobalSettings { deep_verify_interval_secs: 604_800, io_priority: "low".to_string(), log_level: "info".to_string(), + schedule: default_schedule(), + } +} + +/// The disabled schedule (V1 behaviour: sync at any time). All seven days are +/// pre-checked so a user who only flips `enabled` gets a sane "all day, every +/// day" window to narrow. +fn default_schedule() -> ScheduleSettings { + ScheduleSettings { + enabled: false, + start_minute: 0, + end_minute: 0, + days: vec![true; 7], + utc_offset_minutes: 0, } } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 6f520840..77852d2e 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -166,7 +166,10 @@ impl TrayIcon { /// yellow-with-`!`) rather than a plain user/auto pause (DESIGN s8.1 yellow)? fn pause_reason_is_network(reason: PauseReason) -> bool { match reason { - PauseReason::Manual | PauseReason::Battery | PauseReason::Metered => false, + PauseReason::Manual + | PauseReason::Battery + | PauseReason::Metered + | PauseReason::Schedule => false, PauseReason::Offline | PauseReason::ServiceDown | PauseReason::NoInternet @@ -265,6 +268,7 @@ fn tooltip_for_pause(reason: PauseReason) -> String { PauseReason::Manual => "tray.tooltip.paused_manual", PauseReason::Battery => "tray.tooltip.paused_battery", PauseReason::Metered => "tray.tooltip.paused_metered", + PauseReason::Schedule => "tray.tooltip.paused_schedule", PauseReason::Offline => "tray.tooltip.offline", PauseReason::NoInternet => "tray.tooltip.no_internet", PauseReason::CaptivePortal => "tray.tooltip.captive_portal", @@ -1068,6 +1072,7 @@ mod tests { PauseReason::Manual, PauseReason::Battery, PauseReason::Metered, + PauseReason::Schedule, ] { assert_eq!( TrayIcon::for_state(&OrchestratorState::Paused { reason }), diff --git a/ui/src/__tests__/settings-components.test.ts b/ui/src/__tests__/settings-components.test.ts index 43a7afb7..63609bec 100644 --- a/ui/src/__tests__/settings-components.test.ts +++ b/ui/src/__tests__/settings-components.test.ts @@ -4,7 +4,7 @@ import { createPinia, setActivePinia } from "pinia"; import { mount, flushPromises } from "@vue/test-utils"; import { i18n } from "../i18n"; -import type { SettingsDto, SourceDto } from "../ipc/types"; +import type { ScheduleSettings, SettingsDto, SourceDto } from "../ipc/types"; // Component tests for the M6 settings UI: the SourceTable row actions, the // AddSourceWizard multi-step flow, and the Rules-tab round-trip. They drive the @@ -72,6 +72,13 @@ function makeSettings(over: Partial = {}): SettingsDto { deepVerifyIntervalSecs: 604800, ioPriority: "low", logLevel: "info", + schedule: { + enabled: false, + startMinute: 0, + endMinute: 0, + days: [true, true, true, true, true, true, true], + utcOffsetMinutes: 0, + }, }, telemetry: { enabled: true, @@ -588,6 +595,55 @@ describe("Settings Rules tab", () => { }); }); + it("schedule window: enable, edit time, and toggle a day each patch the schedule", async () => { + invokeMock.mockImplementation((cmd: string, args: unknown) => { + if (cmd === "get_settings") return Promise.resolve(makeSettings()); + if (cmd === "update_settings") { + const patch = (args as { patch: Record }).patch; + return Promise.resolve(makeSettings(patch as Partial)); + } + return Promise.resolve(undefined); + }); + + const wrapper = mount(Settings, { + props: { tab: "rules" }, + global: globalMountOptions, + }); + await flushPromises(); + + type SchedPatch = { patch?: { global?: { schedule?: ScheduleSettings } } }; + const lastSchedule = (): ScheduleSettings | undefined => + invokeMock.mock.calls + .filter((c) => c[0] === "update_settings" && (c[1] as SchedPatch).patch?.global?.schedule) + .map((c) => (c[1] as SchedPatch).patch!.global!.schedule!) + .pop(); + + // The time/day controls are hidden until the schedule is enabled. + expect(wrapper.find('input[type="time"]').exists()).toBe(false); + + await wrapper.get('[data-testid="schedule-enabled"]').setValue(true); + await flushPromises(); + const enabled = lastSchedule(); + expect(enabled?.enabled).toBe(true); + expect(enabled?.days).toHaveLength(7); + expect(typeof enabled?.utcOffsetMinutes).toBe("number"); + + // The window controls are now visible; editing the start time re-patches + // the local minute-of-day (09:30 -> 570). + const start = wrapper.get('input[type="time"]'); + await start.setValue("09:30"); + await start.trigger("change"); + await flushPromises(); + expect(lastSchedule()?.startMinute).toBe(9 * 60 + 30); + + // Toggling the Sunday (index 0) button flips that day off. + const dayButtons = wrapper.findAll('[data-testid="schedule-setting"] button'); + expect(dayButtons).toHaveLength(7); + await dayButtons[0].trigger("click"); + await flushPromises(); + expect(lastSchedule()?.days[0]).toBe(false); + }); + it("an empty bandwidth cap patches null (unlimited)", async () => { invokeMock.mockImplementation((cmd: string, args: unknown) => { if (cmd === "get_settings") diff --git a/ui/src/__tests__/settings-stores.test.ts b/ui/src/__tests__/settings-stores.test.ts index 1e7010d3..6f3c02bb 100644 --- a/ui/src/__tests__/settings-stores.test.ts +++ b/ui/src/__tests__/settings-stores.test.ts @@ -67,6 +67,13 @@ function makeSettings(over: Partial = {}): SettingsDto { deepVerifyIntervalSecs: 604800, ioPriority: "low", logLevel: "info", + schedule: { + enabled: false, + startMinute: 0, + endMinute: 0, + days: [true, true, true, true, true, true, true], + utcOffsetMinutes: 0, + }, }, telemetry: { enabled: true, diff --git a/ui/src/ipc/types.ts b/ui/src/ipc/types.ts index ab2732a4..6257691d 100644 --- a/ui/src/ipc/types.ts +++ b/ui/src/ipc/types.ts @@ -151,6 +151,19 @@ export interface ExclusionPreview { // --- Settings & misc (SPEC s11.6, s22) --- +export interface ScheduleSettings { + enabled: boolean; + /** Minutes after local midnight the allowed window opens, 0..=1439. */ + startMinute: number; + /** Minutes after local midnight the allowed window closes, 0..=1439. + * end < start wraps past midnight; end == start allows the whole day. */ + endMinute: number; + /** Seven booleans, index 0=Sunday..6=Saturday (matches Date.getDay()). */ + days: boolean[]; + /** Minutes to add to UTC to reach local time (-new Date().getTimezoneOffset()). */ + utcOffsetMinutes: number; +} + export interface GlobalSettings { autoStartOnLogin: boolean; defaultConcurrentUploads: number | null; @@ -161,6 +174,7 @@ export interface GlobalSettings { deepVerifyIntervalSecs: number; ioPriority: string; logLevel: string; + schedule: ScheduleSettings; } export interface TelemetrySettings { @@ -202,6 +216,8 @@ export interface GlobalSettingsPatch { deepVerifyIntervalSecs?: number; ioPriority?: string; logLevel?: string; + /** Present = replace the whole schedule window. */ + schedule?: ScheduleSettings; } export interface TelemetrySettingsPatch { diff --git a/ui/src/locales/en-US.json b/ui/src/locales/en-US.json index 787787c5..395f7274 100644 --- a/ui/src/locales/en-US.json +++ b/ui/src/locales/en-US.json @@ -164,7 +164,23 @@ "never": "Never" }, "telemetryLabel": "Send anonymous usage stats", - "telemetryNote": "Anonymous usage stats only - no file names or contents are ever sent. One click to disable." + "telemetryNote": "Anonymous usage stats only - no file names or contents are ever sent. One click to disable.", + "schedule": { + "label": "Only back up during a schedule window", + "startLabel": "From", + "endLabel": "To", + "daysLabel": "On these days", + "note": "Outside this window backups pause and resume automatically. Times are in this computer's local time zone.", + "day": { + "0": "Sun", + "1": "Mon", + "2": "Tue", + "3": "Wed", + "4": "Thu", + "5": "Fri", + "6": "Sat" + } + } } }, "about": { diff --git a/ui/src/views/Settings.vue b/ui/src/views/Settings.vue index eee75d2b..0d3dcfe3 100644 --- a/ui/src/views/Settings.vue +++ b/ui/src/views/Settings.vue @@ -37,6 +37,27 @@ const vssModes = ["auto", "always", "never"] as const; const bandwidthCapText = ref(""); const concurrentUploadsText = ref(""); +// Schedule-window (DESIGN s17) local mirrors. Times are edited as "HH:MM" +// strings (native ); days[0]=Sunday..[6]=Saturday. +const dayIndices = [0, 1, 2, 3, 4, 5, 6] as const; +const scheduleEnabled = ref(false); +const scheduleStart = ref("00:00"); +const scheduleEnd = ref("00:00"); +const scheduleDays = ref([true, true, true, true, true, true, true]); + +function minutesToHHMM(min: number): string { + const m = ((Math.floor(min) % 1440) + 1440) % 1440; + const hh = String(Math.floor(m / 60)).padStart(2, "0"); + const mm = String(m % 60).padStart(2, "0"); + return `${hh}:${mm}`; +} + +function hhmmToMinutes(value: string): number { + const [h, m] = value.split(":").map((n) => Number(n)); + if (!Number.isFinite(h) || !Number.isFinite(m)) return 0; + return (((h * 60 + m) % 1440) + 1440) % 1440; +} + function go(route: string): void { void router.push(route); } @@ -62,6 +83,17 @@ watch( s.global.bandwidthCapMbps === null ? "" : String(s.global.bandwidthCapMbps); concurrentUploadsText.value = s.global.defaultConcurrentUploads === null ? "" : String(s.global.defaultConcurrentUploads); + // Defensive: a partial global (e.g. an update_settings round-trip that + // echoes only the patched keys) may omit `schedule`; keep the prior local + // values rather than crash the watcher. + const sched = s.global.schedule; + if (sched) { + scheduleEnabled.value = sched.enabled; + scheduleStart.value = minutesToHHMM(sched.startMinute); + scheduleEnd.value = minutesToHHMM(sched.endMinute); + // Coerce to exactly seven booleans regardless of what was stored. + scheduleDays.value = dayIndices.map((i) => sched.days?.[i] ?? true); + } }, { immediate: true } ); @@ -124,6 +156,34 @@ async function setIoPriority(event: Event): Promise { await settings.patch({ global: { ioPriority: value } }); } +// Persist the whole schedule window. The UTC offset is captured fresh from +// this machine on every save (DESIGN s17 - driven-core stays tz-database-free +// and reasons from a fixed offset). `getTimezoneOffset()` returns minutes to +// SUBTRACT to reach UTC, so negate it to get "minutes to add to UTC". +async function commitSchedule(): Promise { + await settings.patch({ + global: { + schedule: { + enabled: scheduleEnabled.value, + startMinute: hhmmToMinutes(scheduleStart.value), + endMinute: hhmmToMinutes(scheduleEnd.value), + days: [...scheduleDays.value], + utcOffsetMinutes: -new Date().getTimezoneOffset(), + }, + }, + }); +} + +async function setScheduleEnabled(event: Event): Promise { + scheduleEnabled.value = (event.target as HTMLInputElement).checked; + await commitSchedule(); +} + +async function toggleScheduleDay(index: number): Promise { + scheduleDays.value[index] = !scheduleDays.value[index]; + await commitSchedule(); +} + async function setVssMode(event: Event): Promise { const value = (event.target as HTMLSelectElement).value; await settings.patch({ windows: { vssMode: value } }); @@ -198,6 +258,68 @@ async function setTelemetryEnabled(event: Event): Promise { {{ t("settings.rules.skipOnMeteredLabel") }} +
+ +
+
+ + +
+
+ {{ + t("settings.rules.schedule.daysLabel") + }} +
+ +
+
+

+ {{ t("settings.rules.schedule.note") }} +

+
+
+