From d12212ae364f428a8c82c69503b5968d7ad7a3f1 Mon Sep 17 00:00:00 2001 From: ykisana Date: Sat, 10 Oct 2026 13:19:40 -0600 Subject: [PATCH] fix: schedule overrides --- Normal/Models/BlockSchedule.swift | 8 ++- Normal/Services/ScheduleService.swift | 4 +- NormalMonitor/NormalMonitor.swift | 6 ++- .../Services/ScheduleServiceTests.swift | 22 ++++++++ NormalTests/Shared/SharedStoreTests.swift | 50 +++++++++++++++++-- NormalTests/TestSupport/FakeSharedStore.swift | 6 +++ Shared/SharedConstants.swift | 1 + Shared/SharedDTO.swift | 6 ++- Shared/SharedStore.swift | 14 ++++++ Shared/SharedStoreProviding.swift | 18 +++++-- 10 files changed, 121 insertions(+), 14 deletions(-) diff --git a/Normal/Models/BlockSchedule.swift b/Normal/Models/BlockSchedule.swift index 261556a..4978b79 100644 --- a/Normal/Models/BlockSchedule.swift +++ b/Normal/Models/BlockSchedule.swift @@ -100,7 +100,11 @@ final class BlockSchedule: Identifiable { } func isActive(at now: Date, calendar: Calendar = .current) -> Bool { - guard isEnabled else { return false } + activeWindowStart(at: now, calendar: calendar) != nil + } + + func activeWindowStart(at now: Date, calendar: Calendar = .current) -> Date? { + guard isEnabled else { return nil } return ScheduleWindow.start( containing: now, startHour: startHour, @@ -108,7 +112,7 @@ final class BlockSchedule: Identifiable { durationMinutes: effectiveDurationMinutes, weekdays: weekdays, calendar: calendar - ) != nil + ) } func toDTO() -> ScheduleDTO? { diff --git a/Normal/Services/ScheduleService.swift b/Normal/Services/ScheduleService.swift index 062e367..349bd7a 100644 --- a/Normal/Services/ScheduleService.swift +++ b/Normal/Services/ScheduleService.swift @@ -102,10 +102,10 @@ final class ScheduleService { _ schedule: BlockSchedule, screenTimeService: any ScreenTimeProviding ) { - guard schedule.isActive(at: .now) else { return } + guard let windowStart = schedule.activeWindowStart(at: .now) else { return } let domains = effectiveDomains(schedule) if schedule.shouldBlock { - guard !sharedStore.isUnblockAllInEffect() else { return } + guard !sharedStore.suppressesSchedule(windowStart: windowStart) else { return } screenTimeService.addToShields(selection: schedule.selection, customDomains: domains) } else { screenTimeService.removeFromShields(selection: schedule.selection, customDomains: domains) diff --git a/NormalMonitor/NormalMonitor.swift b/NormalMonitor/NormalMonitor.swift index 8530fcd..5808024 100644 --- a/NormalMonitor/NormalMonitor.swift +++ b/NormalMonitor/NormalMonitor.swift @@ -94,11 +94,13 @@ final class NormalMonitor: DeviceActivityMonitor { private func handleScheduleIntervalStart(activityName: String) { guard let schedule = findSchedule(activityName: activityName), - schedule.isActive(at: .now.addingTimeInterval(Self.scheduleStartToleranceSeconds)), + let windowStart = schedule.activeWindowStart( + at: .now.addingTimeInterval(Self.scheduleStartToleranceSeconds) + ), let selection = try? FamilyActivitySelection.fromData(schedule.selectionData) else { return } - guard sharedStore.resolveScheduleStart() == .apply else { return } + guard sharedStore.resolveScheduleStart(windowStart: windowStart) == .apply else { return } let domains = gatedDomains(schedule.customDomains) if schedule.shouldBlock { diff --git a/NormalTests/Services/ScheduleServiceTests.swift b/NormalTests/Services/ScheduleServiceTests.swift index 86955e4..95102c6 100644 --- a/NormalTests/Services/ScheduleServiceTests.swift +++ b/NormalTests/Services/ScheduleServiceTests.swift @@ -183,6 +183,28 @@ struct ScheduleServiceTests { "Foreground re-application must not consume the override; only a fresh start does") } + @Test func overrideSetBeforeWindowStartedDoesNotSuppressIt() throws { + let (service, _, store) = makeService() + let screenTime = FakeScreenTimeService() + store.scheduleOverrideActive = true + store.scheduleOverrideSince = .now.addingTimeInterval(-.hours(3)) + + try service.sync(activeNowSchedule(), screenTimeService: screenTime) + + #expect(screenTime.addToShieldsCalled) + } + + @Test func legacyOverrideWithoutTimestampSuppressesActiveSchedule() throws { + let (service, _, store) = makeService() + let screenTime = FakeScreenTimeService() + store.scheduleOverrideActive = true + + try service.sync(activeNowSchedule(), screenTimeService: screenTime) + + #expect(!screenTime.addToShieldsCalled) + #expect(store.isScheduleOverrideActive()) + } + @Test func setScheduleOverrideWritesFlag() { let (service, _, store) = makeService() diff --git a/NormalTests/Shared/SharedStoreTests.swift b/NormalTests/Shared/SharedStoreTests.swift index 968fe80..3df96f3 100644 --- a/NormalTests/Shared/SharedStoreTests.swift +++ b/NormalTests/Shared/SharedStoreTests.swift @@ -116,16 +116,57 @@ struct SharedStoreTests { @Test func resolveScheduleStartAppliesWhenNothingActive() { let (store, _) = makeStore() - #expect(store.resolveScheduleStart() == .apply) + #expect(store.resolveScheduleStart(windowStart: .now) == .apply) } @Test func resolveScheduleStartConsumesPermanentOverrideThenApplies() { let (store, _) = makeStore() store.setScheduleOverrideActive(true) + let nextWindow = Date.now.addingTimeInterval(.minutes(1)) - #expect(store.resolveScheduleStart() == .apply, "The start proceeds (override ends here)") + #expect(store.resolveScheduleStart(windowStart: nextWindow) == .apply, "The start proceeds (override ends here)") #expect(!store.isScheduleOverrideActive(), "...and the one-shot override is consumed") - #expect(store.resolveScheduleStart() == .apply, "Subsequent starts apply normally") + #expect(store.resolveScheduleStart(windowStart: nextWindow) == .apply, "Subsequent starts apply normally") + } + + @Test func refiredStartOfWindowBegunBeforeOverrideSkipsWithoutConsuming() { + let (store, _) = makeStore() + let runningWindow = Date.now.addingTimeInterval(-.hours(1)) + store.setScheduleOverrideActive(true) + + #expect(store.resolveScheduleStart(windowStart: runningWindow) == .skip) + #expect(store.resolveScheduleStart(windowStart: runningWindow) == .skip) + #expect(store.isScheduleOverrideActive()) + #expect(store.suppressesSchedule(windowStart: runningWindow)) + } + + @Test func overrideDoesNotSuppressWindowStartedAfterIt() { + let (store, _) = makeStore() + store.setScheduleOverrideActive(true) + + #expect(!store.suppressesSchedule(windowStart: .now.addingTimeInterval(.minutes(1)))) + } + + @Test func legacyOverrideWithoutTimestampKeepsOneShotSemantics() { + let (store, defaults) = makeStore() + defaults.set(true, forKey: SharedConstants.DefaultsKey.scheduleOverride) + let runningWindow = Date.now.addingTimeInterval(-.hours(1)) + + #expect(store.suppressesSchedule(windowStart: runningWindow)) + #expect(store.resolveScheduleStart(windowStart: runningWindow) == .apply) + #expect(!store.isScheduleOverrideActive()) + } + + @Test func settingOverrideWritesLegacyFlagAndTimestamp() { + let (store, defaults) = makeStore() + + store.setScheduleOverrideActive(true) + #expect(defaults.bool(forKey: SharedConstants.DefaultsKey.scheduleOverride)) + #expect(store.loadScheduleOverrideSince() != nil) + + store.setScheduleOverrideActive(false) + #expect(!defaults.bool(forKey: SharedConstants.DefaultsKey.scheduleOverride)) + #expect(store.loadScheduleOverrideSince() == nil) } @Test func resolveScheduleStartSkipsDuringTimedUnblockWithoutConsumingOverride() { @@ -133,7 +174,8 @@ struct SharedStoreTests { store.upsertTimedUnblock(makeDTO(id: "main")) store.setScheduleOverrideActive(true) - #expect(store.resolveScheduleStart() == .skip, "A live timed unblock wins for its whole window") + #expect(store.resolveScheduleStart(windowStart: .now.addingTimeInterval(.minutes(1))) == .skip, + "A live timed unblock wins for its whole window") #expect(store.isScheduleOverrideActive(), "A timed-unblock skip must not consume the permanent override") } diff --git a/NormalTests/TestSupport/FakeSharedStore.swift b/NormalTests/TestSupport/FakeSharedStore.swift index cc226ac..0da8d8f 100644 --- a/NormalTests/TestSupport/FakeSharedStore.swift +++ b/NormalTests/TestSupport/FakeSharedStore.swift @@ -5,6 +5,7 @@ final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable { var timedUnblocks: [TimedUnblockDTO] = [] var schedules: [ScheduleDTO] = [] var scheduleOverrideActive = false + var scheduleOverrideSince: Date? var customDomainsEnabled = false var usageLimits: [UsageLimitDTO] = [] var usageDayState: UsageDayStateDTO = .empty @@ -50,6 +51,11 @@ final class FakeSharedStore: SharedStoreProviding, @unchecked Sendable { func setScheduleOverrideActive(_ active: Bool) { scheduleOverrideActive = active + scheduleOverrideSince = active ? .now : nil + } + + func loadScheduleOverrideSince() -> Date? { + scheduleOverrideSince } func isCustomDomainsEnabled() -> Bool { diff --git a/Shared/SharedConstants.swift b/Shared/SharedConstants.swift index 365a53f..b92fe54 100644 --- a/Shared/SharedConstants.swift +++ b/Shared/SharedConstants.swift @@ -7,6 +7,7 @@ nonisolated enum SharedConstants { static let timedUnblocks = "timedUnblocks_v1" static let schedules = "schedules_v1" static let scheduleOverride = "scheduleOverride_v1" + static let scheduleOverrideSince = "scheduleOverrideSince_v1" static let customDomainsEnabled = "customDomainsEnabled_v1" static let widgetGroups = "widgetGroups_v1" static let widgetKeyTypes = "widgetKeyTypes_v1" diff --git a/Shared/SharedDTO.swift b/Shared/SharedDTO.swift index 7998034..d6a4b94 100644 --- a/Shared/SharedDTO.swift +++ b/Shared/SharedDTO.swift @@ -84,6 +84,10 @@ extension ScheduleDTO { } func isActive(at date: Date, calendar: Calendar = .current) -> Bool { + activeWindowStart(at: date, calendar: calendar) != nil + } + + func activeWindowStart(at date: Date, calendar: Calendar = .current) -> Date? { ScheduleWindow.start( containing: date, startHour: startHour, @@ -91,7 +95,7 @@ extension ScheduleDTO { durationMinutes: durationMinutes, weekdays: weekdays, calendar: calendar - ) != nil + ) } func endApplies(on date: Date, calendar: Calendar = .current) -> Bool { diff --git a/Shared/SharedStore.swift b/Shared/SharedStore.swift index be52d12..228b045 100644 --- a/Shared/SharedStore.swift +++ b/Shared/SharedStore.swift @@ -63,6 +63,20 @@ struct SharedStore: SharedStoreProviding, Sendable { func setScheduleOverrideActive(_ active: Bool) { defaults.set(active, forKey: SharedConstants.DefaultsKey.scheduleOverride) + if active { + defaults.set( + Date.now.timeIntervalSinceReferenceDate, + forKey: SharedConstants.DefaultsKey.scheduleOverrideSince + ) + } else { + defaults.removeObject(forKey: SharedConstants.DefaultsKey.scheduleOverrideSince) + } + } + + func loadScheduleOverrideSince() -> Date? { + guard let value = defaults.object(forKey: SharedConstants.DefaultsKey.scheduleOverrideSince) as? Double + else { return nil } + return Date(timeIntervalSinceReferenceDate: value) } func isCustomDomainsEnabled() -> Bool { diff --git a/Shared/SharedStoreProviding.swift b/Shared/SharedStoreProviding.swift index 353942b..9126b89 100644 --- a/Shared/SharedStoreProviding.swift +++ b/Shared/SharedStoreProviding.swift @@ -11,6 +11,7 @@ nonisolated protocol SharedStoreProviding: Sendable { func loadSchedules() -> [ScheduleDTO] func isScheduleOverrideActive() -> Bool func setScheduleOverrideActive(_ active: Bool) + func loadScheduleOverrideSince() -> Date? func isCustomDomainsEnabled() -> Bool func setCustomDomainsEnabled(_ enabled: Bool) func saveUsageLimits(_ limits: [UsageLimitDTO]) @@ -35,13 +36,24 @@ extension SharedStoreProviding { isMainTimedUnblockActive() || isScheduleOverrideActive() } - func resolveScheduleStart() -> ScheduleStartDecision { + func suppressesSchedule(windowStart: Date) -> Bool { + if isMainTimedUnblockActive() { + return true + } + guard isScheduleOverrideActive() else { return false } + guard let since = loadScheduleOverrideSince() else { return true } + return windowStart < since + } + + func resolveScheduleStart(windowStart: Date) -> ScheduleStartDecision { if isMainTimedUnblockActive() { return .skip } - if isScheduleOverrideActive() { - setScheduleOverrideActive(false) + guard isScheduleOverrideActive() else { return .apply } + if let since = loadScheduleOverrideSince(), windowStart < since { + return .skip } + setScheduleOverrideActive(false) return .apply }