Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions Normal/Models/BlockSchedule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -99,7 +105,7 @@ final class BlockSchedule: Identifiable {
containing: now,
startHour: startHour,
startMinute: startMinute,
durationMinutes: durationMinutes,
durationMinutes: effectiveDurationMinutes,
weekdays: weekdays,
calendar: calendar
) != nil
Expand All @@ -113,7 +119,7 @@ final class BlockSchedule: Identifiable {
selectionData: data,
startHour: startHour,
startMinute: startMinute,
durationMinutes: durationMinutes,
durationMinutes: effectiveDurationMinutes,
weekdays: weekdays,
shouldBlock: shouldBlock,
isTimed: isTimed,
Expand Down
10 changes: 6 additions & 4 deletions Normal/Services/ScheduleService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 2 additions & 4 deletions Normal/Views/Schedules/ScheduleFormSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)")
Expand Down
58 changes: 58 additions & 0 deletions NormalTests/Services/ScheduleServiceTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import DeviceActivity
import FamilyControls
import Foundation
@testable import Normal
Expand Down Expand Up @@ -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)
}
}
Loading