@@ -326,6 +326,109 @@ pub enum PauseReason {
326326 /// DNS broken; SPEC s24 `net.dns_failed`). Kept distinct from [`Offline`]
327327 /// per CODEX_NOTES P2-9 (M4).
328328 DnsFailed ,
329+ /// Outside the user's configured schedule window (V2 schedule windows,
330+ /// DESIGN s17). The orchestrator resumes automatically once the local
331+ /// clock re-enters the allowed window - no manual action required.
332+ Schedule ,
333+ }
334+
335+ // -----------------------------------------------------------------------------
336+ // ScheduleConfig (V2 schedule windows - DESIGN s17)
337+ // -----------------------------------------------------------------------------
338+
339+ /// A time-of-day + day-of-week window during which sync is allowed (V2
340+ /// schedule windows, DESIGN s17 "only sync 23:00-06:00").
341+ ///
342+ /// The window is expressed in the user's LOCAL wall-clock time. Like the
343+ /// pacer's "midnight Pacific" quota boundary (see [`crate::pacer`]),
344+ /// `driven-core` stays free of a timezone database: local time is derived
345+ /// from a fixed [`Self::utc_offset_minutes`] the app layer captures from the
346+ /// OS / browser. The bounded consequence is the same as the pacer's - across
347+ /// a DST transition the window shifts by up to an hour until the app
348+ /// re-reads the offset. This is deliberate and documented (DESIGN s17).
349+ ///
350+ /// The predicate is a pure function of the injected [`Clock`](crate::time::Clock)
351+ /// reading, so the orchestrator gate is deterministic under `FakeClock`.
352+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Serialize , Deserialize ) ]
353+ pub struct ScheduleConfig {
354+ /// When `false` the schedule never gates (sync runs at any time). This is
355+ /// the V1 behaviour and the [`Default`].
356+ pub enabled : bool ,
357+ /// Minutes after local midnight the allowed window opens, `0..=1439`.
358+ pub start_minute : u16 ,
359+ /// Minutes after local midnight the allowed window closes, `0..=1439`.
360+ ///
361+ /// - `end > start`: a same-day window `[start, end)`.
362+ /// - `end < start`: the window wraps past midnight (active `[start, 1440)`
363+ /// and `[0, end)`).
364+ /// - `end == start`: the whole day is allowed (only [`Self::days`] gates).
365+ pub end_minute : u16 ,
366+ /// Which local days the window is active on, indexed `0 = Sunday ..=
367+ /// 6 = Saturday` to match JavaScript's `Date.getDay()`. The window is
368+ /// evaluated against the CURRENT local day, so a window that wraps past
369+ /// midnight (e.g. 23:00-06:00) needs both the evening day and the
370+ /// following morning's day enabled for the whole window to be allowed.
371+ pub days : [ bool ; 7 ] ,
372+ /// Minutes to ADD to UTC to reach the user's local wall-clock time
373+ /// (e.g. `-480` for PST = UTC-8). The app layer sets this from the OS;
374+ /// the browser value is `-new Date().getTimezoneOffset()`.
375+ pub utc_offset_minutes : i16 ,
376+ }
377+
378+ impl Default for ScheduleConfig {
379+ /// Disabled: sync runs at any time (V1 behaviour). The window fields are
380+ /// inert while `enabled` is false.
381+ fn default ( ) -> Self {
382+ Self {
383+ enabled : false ,
384+ start_minute : 0 ,
385+ end_minute : 0 ,
386+ days : [ true ; 7 ] ,
387+ utc_offset_minutes : 0 ,
388+ }
389+ }
390+ }
391+
392+ impl ScheduleConfig {
393+ /// Milliseconds per minute / minutes per day, for the local-time maths.
394+ const MS_PER_MIN : i64 = 60_000 ;
395+ const MINS_PER_DAY : i64 = 1_440 ;
396+
397+ /// True if sync is allowed at the wall-clock instant `now_ms`.
398+ ///
399+ /// A disabled schedule always allows. Otherwise the UTC instant is shifted
400+ /// into local wall time by [`Self::utc_offset_minutes`], reduced to a
401+ /// local day-of-week + minute-of-day, and tested against the window. Uses
402+ /// Euclidean division/remainder so a negative (pre-epoch) or
403+ /// backwards-jumped clock reading still yields an in-range day/minute
404+ /// rather than a panic (DESIGN s18.7 - the clock may move backwards).
405+ pub fn allows ( & self , now_ms : UnixMs ) -> bool {
406+ if !self . enabled {
407+ return true ;
408+ }
409+ let local_ms = now_ms. saturating_add ( ( self . utc_offset_minutes as i64 ) * Self :: MS_PER_MIN ) ;
410+ let total_min = local_ms. div_euclid ( Self :: MS_PER_MIN ) ;
411+ let min_of_day = total_min. rem_euclid ( Self :: MINS_PER_DAY ) as u16 ;
412+ // Days since the Unix epoch in local time. 1970-01-01 was a Thursday,
413+ // which is `getDay() == 4`, so offset the day count by 4 before the
414+ // mod-7 to land on the Sunday-indexed weekday.
415+ let day_index = total_min. div_euclid ( Self :: MINS_PER_DAY ) ;
416+ let dow = ( day_index + 4 ) . rem_euclid ( 7 ) as usize ;
417+ if !self . days [ dow] {
418+ return false ;
419+ }
420+ let ( s, e) = ( self . start_minute , self . end_minute ) ;
421+ if s == e {
422+ // Whole day allowed; only the day-of-week gates.
423+ return true ;
424+ }
425+ if s < e {
426+ min_of_day >= s && min_of_day < e
427+ } else {
428+ // Wraps past midnight.
429+ min_of_day >= s || min_of_day < e
430+ }
431+ }
329432}
330433
331434// -----------------------------------------------------------------------------
@@ -1209,4 +1312,113 @@ mod tests {
12091312 let rp: RelativePath = std:: path:: Path :: new ( "a/b.txt" ) . try_into ( ) . unwrap ( ) ;
12101313 assert_eq ! ( rp. as_str( ) , "a/b.txt" ) ;
12111314 }
1315+
1316+ // --- ScheduleConfig (V2 schedule windows) -------------------------------
1317+
1318+ /// Monday 2024-01-01 00:00:00 UTC, in epoch ms. The dow formula resolves
1319+ /// this to `getDay() == 1` (Monday); used as the anchor for the cases
1320+ /// below (offsets in minutes/days are added on top).
1321+ const MON_2024_01_01_UTC_MS : UnixMs = 1_704_067_200_000 ;
1322+ const MIN_MS : UnixMs = 60_000 ;
1323+ const DAY_MS : UnixMs = 1_440 * MIN_MS ;
1324+
1325+ fn all_days ( ) -> [ bool ; 7 ] {
1326+ [ true ; 7 ]
1327+ }
1328+
1329+ #[ test]
1330+ fn schedule_disabled_always_allows ( ) {
1331+ let s = ScheduleConfig :: default ( ) ;
1332+ assert ! ( !s. enabled) ;
1333+ assert ! ( s. allows( MON_2024_01_01_UTC_MS ) ) ;
1334+ assert ! ( s. allows( 0 ) ) ;
1335+ assert ! ( s. allows( -1 ) ) ; // pre-epoch must not panic
1336+ }
1337+
1338+ #[ test]
1339+ fn schedule_same_day_window_half_open ( ) {
1340+ // 09:00-17:00 every day.
1341+ let s = ScheduleConfig {
1342+ enabled : true ,
1343+ start_minute : 9 * 60 ,
1344+ end_minute : 17 * 60 ,
1345+ days : all_days ( ) ,
1346+ utc_offset_minutes : 0 ,
1347+ } ;
1348+ let at = |min : i64 | s. allows ( MON_2024_01_01_UTC_MS + min * MIN_MS ) ;
1349+ assert ! ( !at( 0 ) ) ; // 00:00 - before
1350+ assert ! ( !at( 8 * 60 + 59 ) ) ; // 08:59 - before
1351+ assert ! ( at( 9 * 60 ) ) ; // 09:00 - open (inclusive)
1352+ assert ! ( at( 16 * 60 + 59 ) ) ; // 16:59 - inside
1353+ assert ! ( !at( 17 * 60 ) ) ; // 17:00 - close (exclusive)
1354+ assert ! ( !at( 23 * 60 ) ) ; // 23:00 - after
1355+ }
1356+
1357+ #[ test]
1358+ fn schedule_wrap_past_midnight ( ) {
1359+ // 23:00-06:00 every day.
1360+ let s = ScheduleConfig {
1361+ enabled : true ,
1362+ start_minute : 23 * 60 ,
1363+ end_minute : 6 * 60 ,
1364+ days : all_days ( ) ,
1365+ utc_offset_minutes : 0 ,
1366+ } ;
1367+ let at = |min : i64 | s. allows ( MON_2024_01_01_UTC_MS + min * MIN_MS ) ;
1368+ assert ! ( at( 23 * 60 ) ) ; // 23:00 - open
1369+ assert ! ( at( 23 * 60 + 30 ) ) ; // 23:30 - evening tail
1370+ assert ! ( at( 0 ) ) ; // 00:00 - past midnight
1371+ assert ! ( at( 5 * 60 + 59 ) ) ; // 05:59 - morning
1372+ assert ! ( !at( 6 * 60 ) ) ; // 06:00 - close (exclusive)
1373+ assert ! ( !at( 12 * 60 ) ) ; // noon - outside
1374+ }
1375+
1376+ #[ test]
1377+ fn schedule_equal_bounds_is_whole_day ( ) {
1378+ // start == end => only the day-of-week gates.
1379+ let s = ScheduleConfig {
1380+ enabled : true ,
1381+ start_minute : 0 ,
1382+ end_minute : 0 ,
1383+ days : all_days ( ) ,
1384+ utc_offset_minutes : 0 ,
1385+ } ;
1386+ for h in [ 0 , 6 , 12 , 18 , 23 ] {
1387+ assert ! ( s. allows( MON_2024_01_01_UTC_MS + h * 60 * MIN_MS ) ) ;
1388+ }
1389+ }
1390+
1391+ #[ test]
1392+ fn schedule_day_of_week_gates ( ) {
1393+ // Whole-day window, but only Monday (index 1) enabled.
1394+ let mut days = [ false ; 7 ] ;
1395+ days[ 1 ] = true ; // Monday
1396+ let s = ScheduleConfig {
1397+ enabled : true ,
1398+ start_minute : 0 ,
1399+ end_minute : 0 ,
1400+ days,
1401+ utc_offset_minutes : 0 ,
1402+ } ;
1403+ assert ! ( s. allows( MON_2024_01_01_UTC_MS ) ) ; // Monday
1404+ assert ! ( !s. allows( MON_2024_01_01_UTC_MS + DAY_MS ) ) ; // Tuesday
1405+ assert ! ( !s. allows( MON_2024_01_01_UTC_MS - DAY_MS ) ) ; // Sunday
1406+ assert ! ( s. allows( MON_2024_01_01_UTC_MS + 7 * DAY_MS ) ) ; // next Monday
1407+ }
1408+
1409+ #[ test]
1410+ fn schedule_utc_offset_shifts_local_time ( ) {
1411+ // 00:00-01:00 LOCAL, every day, at UTC+1. 00:00 UTC == 01:00 local,
1412+ // which is outside [00:00, 01:00); one hour earlier (23:00 UTC) ==
1413+ // 00:00 local, which is inside.
1414+ let s = ScheduleConfig {
1415+ enabled : true ,
1416+ start_minute : 0 ,
1417+ end_minute : 60 ,
1418+ days : all_days ( ) ,
1419+ utc_offset_minutes : 60 ,
1420+ } ;
1421+ assert ! ( !s. allows( MON_2024_01_01_UTC_MS ) ) ; // 01:00 local
1422+ assert ! ( s. allows( MON_2024_01_01_UTC_MS - 60 * MIN_MS ) ) ; // 00:00 local
1423+ }
12121424}
0 commit comments