From 67654716147d164443cf6bb2cff8928949de8308 Mon Sep 17 00:00:00 2001 From: ykisana Date: Sat, 10 Oct 2026 13:12:27 -0600 Subject: [PATCH] fix: minimum schedule --- Normal/Models/BlockSchedule.swift | 10 +++- Normal/Services/ScheduleService.swift | 10 ++-- .../Views/Schedules/ScheduleFormSheet.swift | 6 +- .../Services/ScheduleServiceTests.swift | 58 +++++++++++++++++++ 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/Normal/Models/BlockSchedule.swift b/Normal/Models/BlockSchedule.swift index 9e3055c..261556a 100644 --- a/Normal/Models/BlockSchedule.swift +++ b/Normal/Models/BlockSchedule.swift @@ -39,6 +39,12 @@ final class BlockSchedule: Identifiable { return date.formatted(date: .omitted, time: .shortened) } + static let minimumDurationMinutes = 15 + + var effectiveDurationMinutes: Int { + max(durationMinutes, Self.minimumDurationMinutes) + } + var formattedDuration: String { DurationFormat.compact(minutes: durationMinutes) } @@ -99,7 +105,7 @@ final class BlockSchedule: Identifiable { containing: now, startHour: startHour, startMinute: startMinute, - durationMinutes: durationMinutes, + durationMinutes: effectiveDurationMinutes, weekdays: weekdays, calendar: calendar ) != nil @@ -113,7 +119,7 @@ final class BlockSchedule: Identifiable { selectionData: data, startHour: startHour, startMinute: startMinute, - durationMinutes: durationMinutes, + durationMinutes: effectiveDurationMinutes, weekdays: weekdays, shouldBlock: shouldBlock, isTimed: isTimed, diff --git a/Normal/Services/ScheduleService.swift b/Normal/Services/ScheduleService.swift index 00bda67..062e367 100644 --- a/Normal/Services/ScheduleService.swift +++ b/Normal/Services/ScheduleService.swift @@ -130,11 +130,13 @@ final class ScheduleService { start.minute = schedule.startMinute start.second = 0 - let endMinutes = schedule.startHour * 60 + schedule.startMinute + schedule.durationMinutes + let startSeconds = (schedule.startHour * 60 + schedule.startMinute) * 60 + let durationSeconds = min(schedule.effectiveDurationMinutes * 60, 86400 - 1) + let endSeconds = (startSeconds + durationSeconds) % 86400 var end = DateComponents() - end.hour = (endMinutes / 60) % 24 - end.minute = endMinutes % 60 - end.second = 0 + end.hour = endSeconds / 3600 + end.minute = endSeconds % 3600 / 60 + end.second = endSeconds % 60 return DeviceActivitySchedule(intervalStart: start, intervalEnd: end, repeats: true) } diff --git a/Normal/Views/Schedules/ScheduleFormSheet.swift b/Normal/Views/Schedules/ScheduleFormSheet.swift index 0d349ae..79598f9 100644 --- a/Normal/Views/Schedules/ScheduleFormSheet.swift +++ b/Normal/Views/Schedules/ScheduleFormSheet.swift @@ -39,10 +39,8 @@ struct ScheduleFormSheet: View { !isNew && isBlocked } - private static let minimumDurationMinutes = 15 - private var isDurationTooShort: Bool { - isTimed && computedDurationMinutes < Self.minimumDurationMinutes + isTimed && computedDurationMinutes < BlockSchedule.minimumDurationMinutes } private var customDomainsEnabled: Bool { @@ -237,7 +235,7 @@ struct ScheduleFormSheet: View { } footer: { if isTimed { if isDurationTooShort { - Text("Duration must be at least \(Self.minimumDurationMinutes) minutes.") + Text("Duration must be at least \(BlockSchedule.minimumDurationMinutes) minutes.") .foregroundStyle(.red) } else { Text("Duration: \(formattedComputedDuration)") diff --git a/NormalTests/Services/ScheduleServiceTests.swift b/NormalTests/Services/ScheduleServiceTests.swift index 783a6b6..86955e4 100644 --- a/NormalTests/Services/ScheduleServiceTests.swift +++ b/NormalTests/Services/ScheduleServiceTests.swift @@ -1,3 +1,4 @@ +import DeviceActivity import FamilyControls import Foundation @testable import Normal @@ -336,4 +337,61 @@ struct ScheduleServiceTests { service.mirrorCustomDomainsEnabled(false) #expect(!store.isCustomDomainsEnabled()) } + + private func registeredSchedule( + startHour: Int, + startMinute: Int, + durationMinutes: Int, + isTimed: Bool + ) throws -> DeviceActivitySchedule { + let (service, activity, _) = makeService() + let schedule = BlockSchedule( + name: "S", + selection: FamilyActivitySelection(), + startHour: startHour, startMinute: startMinute, + durationMinutes: durationMinutes, + weekdays: [4], + shouldBlock: true, + isTimed: isTimed, + isEnabled: true + ) + try service.sync(schedule, screenTimeService: FakeScreenTimeService()) + return try #require(activity.startCalls.last?.schedule) + } + + @Test func registersRequestedWindow() throws { + let registered = try registeredSchedule(startHour: 22, startMinute: 30, durationMinutes: 240, isTimed: true) + #expect(registered.intervalStart == DateComponents(hour: 22, minute: 30, second: 0)) + #expect(registered.intervalEnd == DateComponents(hour: 2, minute: 30, second: 0)) + #expect(registered.repeats) + } + + @Test func permanentScheduleShorterThanMinimumRegistersMinimumWindow() throws { + let registered = try registeredSchedule(startHour: 9, startMinute: 55, durationMinutes: 5, isTimed: false) + #expect(registered.intervalStart == DateComponents(hour: 9, minute: 55, second: 0)) + #expect(registered.intervalEnd == DateComponents(hour: 10, minute: 10, second: 0)) + } + + @Test func fullDayScheduleEndsOneSecondBeforeStart() throws { + let registered = try registeredSchedule(startHour: 9, startMinute: 0, durationMinutes: 1440, isTimed: true) + #expect(registered.intervalStart == DateComponents(hour: 9, minute: 0, second: 0)) + #expect(registered.intervalEnd == DateComponents(hour: 8, minute: 59, second: 59)) + } + + @Test func shortPermanentScheduleUsesMinimumWindowForActivityAndDTO() throws { + let s = BlockSchedule( + name: "S", + selection: FamilyActivitySelection(), + startHour: 9, startMinute: 0, + durationMinutes: 5, + weekdays: [4], + shouldBlock: true, + isTimed: false, + isEnabled: true + ) + #expect(s.isActive(at: date(weekday: 4, hour: 9, minute: 10), calendar: Self.utc)) + #expect(!s.isActive(at: date(weekday: 4, hour: 9, minute: 15), calendar: Self.utc)) + #expect(try #require(s.toDTO()).durationMinutes == 15) + #expect(s.durationMinutes == 5) + } }