From f99920c95a8a9131d1dac92367a9670ff51ed855 Mon Sep 17 00:00:00 2001 From: ykisana Date: Sat, 10 Oct 2026 13:07:47 -0600 Subject: [PATCH] fix: schedule window check --- Normal/Models/BlockSchedule.swift | 24 ++++-------- NormalMonitor/NormalMonitor.swift | 3 +- .../Shared/ScheduleActiveDayTests.swift | 24 ++++++++++-- Shared/SharedDTO.swift | 38 ++++++++++++++++++- 4 files changed, 66 insertions(+), 23 deletions(-) diff --git a/Normal/Models/BlockSchedule.swift b/Normal/Models/BlockSchedule.swift index 7f33cca..9e3055c 100644 --- a/Normal/Models/BlockSchedule.swift +++ b/Normal/Models/BlockSchedule.swift @@ -95,22 +95,14 @@ final class BlockSchedule: Identifiable { func isActive(at now: Date, calendar: Calendar = .current) -> Bool { guard isEnabled else { return false } - - let startOfToday = calendar.startOfDay(for: now) - for dayOffset in [0, -1] { - guard let day = calendar.date(byAdding: .day, value: dayOffset, to: startOfToday), - let start = calendar.date( - bySettingHour: startHour, minute: startMinute, second: 0, of: day - ) - else { continue } - let end = start + .minutes(durationMinutes) - if weekdays.contains(calendar.component(.weekday, from: start)), - now >= start, now < end - { - return true - } - } - return false + return ScheduleWindow.start( + containing: now, + startHour: startHour, + startMinute: startMinute, + durationMinutes: durationMinutes, + weekdays: weekdays, + calendar: calendar + ) != nil } func toDTO() -> ScheduleDTO? { diff --git a/NormalMonitor/NormalMonitor.swift b/NormalMonitor/NormalMonitor.swift index 688130a..8530fcd 100644 --- a/NormalMonitor/NormalMonitor.swift +++ b/NormalMonitor/NormalMonitor.swift @@ -9,6 +9,7 @@ final class NormalMonitor: DeviceActivityMonitor { private let limitStore = ManagedSettingsStore(named: .dailyLimits) private static let thresholdGraceSeconds: TimeInterval = 10 + private static let scheduleStartToleranceSeconds: TimeInterval = 60 override func intervalDidStart(for activity: DeviceActivityName) { let name = activity.rawValue @@ -93,7 +94,7 @@ final class NormalMonitor: DeviceActivityMonitor { private func handleScheduleIntervalStart(activityName: String) { guard let schedule = findSchedule(activityName: activityName), - schedule.startApplies(on: .now), + schedule.isActive(at: .now.addingTimeInterval(Self.scheduleStartToleranceSeconds)), let selection = try? FamilyActivitySelection.fromData(schedule.selectionData) else { return } diff --git a/NormalTests/Shared/ScheduleActiveDayTests.swift b/NormalTests/Shared/ScheduleActiveDayTests.swift index feea7f3..bebbbee 100644 --- a/NormalTests/Shared/ScheduleActiveDayTests.swift +++ b/NormalTests/Shared/ScheduleActiveDayTests.swift @@ -37,14 +37,30 @@ struct ScheduleActiveDayTests { 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, 7: 6, 8: 7, ] - @Test func nonWrappingMondayToFridayStartOnlyOnWeekdays() { + @Test func nonWrappingMondayToFridayActiveOnlyOnWeekdays() { let s = schedule(weekdays: [2, 3, 4, 5, 6]) for (day, weekday) in weekdayOfDay { let expected = (2 ... 6).contains(weekday) - #expect(s.startApplies(on: date(day: day), calendar: cal) == expected) + #expect(s.isActive(at: date(day: day, hour: 9), calendar: cal) == expected) } } + @Test func nonWrappingInactiveOutsideWindow() { + let s = schedule(weekdays: [2, 3, 4, 5, 6]) + #expect(!s.isActive(at: date(day: 3, hour: 8), calendar: cal)) + #expect(!s.isActive(at: date(day: 3, hour: 10), calendar: cal)) + } + + @Test func wrappingStartAfterMidnightUsesDayWindowStarted() { + // Tuesday-only 22:00–02:00. June 4 2024 is a Tuesday. + let s = schedule(weekdays: [3], startHour: 22, durationMinutes: 240) + + #expect(!s.isActive(at: date(day: 4, hour: 1), calendar: cal)) + #expect(s.isActive(at: date(day: 4, hour: 23), calendar: cal)) + #expect(s.isActive(at: date(day: 5, hour: 1), calendar: cal)) + #expect(!s.isActive(at: date(day: 5, hour: 23), calendar: cal)) + } + @Test func nonWrappingEndDoesNotFireOnUnselectedDays() { let s = schedule(weekdays: [2, 3, 4, 5, 6]) #expect(!s.endApplies(on: date(day: 8), calendar: cal)) @@ -62,8 +78,8 @@ struct ScheduleActiveDayTests { @Test func wrappingFridayNightEndFiresSaturdayNotOtherDays() { let s = schedule(weekdays: [6], startHour: 23, startMinute: 0, durationMinutes: 120) - #expect(s.startApplies(on: date(day: 7), calendar: cal)) - #expect(!s.startApplies(on: date(day: 8), calendar: cal)) + #expect(s.isActive(at: date(day: 7, hour: 23), calendar: cal)) + #expect(!s.isActive(at: date(day: 8, hour: 23), calendar: cal)) #expect(s.endApplies(on: date(day: 8), calendar: cal)) #expect(!s.endApplies(on: date(day: 7), calendar: cal)) #expect(!s.endApplies(on: date(day: 2), calendar: cal)) diff --git a/Shared/SharedDTO.swift b/Shared/SharedDTO.swift index c727129..7998034 100644 --- a/Shared/SharedDTO.swift +++ b/Shared/SharedDTO.swift @@ -83,8 +83,15 @@ extension ScheduleDTO { startHour * 60 + startMinute + durationMinutes >= 24 * 60 } - func startApplies(on date: Date, calendar: Calendar = .current) -> Bool { - weekdays.contains(calendar.component(.weekday, from: date)) + func isActive(at date: Date, calendar: Calendar = .current) -> Bool { + ScheduleWindow.start( + containing: date, + startHour: startHour, + startMinute: startMinute, + durationMinutes: durationMinutes, + weekdays: weekdays, + calendar: calendar + ) != nil } func endApplies(on date: Date, calendar: Calendar = .current) -> Bool { @@ -111,3 +118,30 @@ extension FamilyActivitySelection { return merged } } + +nonisolated enum ScheduleWindow { + static func start( + containing date: Date, + startHour: Int, + startMinute: Int, + durationMinutes: Int, + weekdays: Set, + calendar: Calendar = .current + ) -> Date? { + let startOfToday = calendar.startOfDay(for: date) + for dayOffset in [0, -1] { + guard let day = calendar.date(byAdding: .day, value: dayOffset, to: startOfToday), + let start = calendar.date( + bySettingHour: startHour, minute: startMinute, second: 0, of: day + ) + else { continue } + let end = start.addingTimeInterval(TimeInterval(durationMinutes * 60)) + if weekdays.contains(calendar.component(.weekday, from: start)), + date >= start, date < end + { + return start + } + } + return nil + } +}