Add reminder notifications to schedules
- ScheduleDocument/Schedule gain optional reminderMinutes (minutes from midnight; decode-compatible, no schema bump) - New Workout form gets a Reminder section (toggle + time picker, hidden for Now; footer warns when notifications are denied) - ReminderPlanner (pure, tested) derives notification triggers: daily repeating, per-weekday repeating for fixed days, one-shot for once - ReminderScheduler resyncs pending requests from the cache on every change, multiplexed onto onCacheChanged after the watch push; asks notification permission only once a reminder actually exists
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Workouts
|
||||
|
||||
/// Locks `ReminderPlanner`'s pure trigger derivation — schedule snapshots in,
|
||||
/// (identifier, DateComponents, repeats) plans out. `UNUserNotificationCenter`
|
||||
/// itself is deliberately untested; the scheduler is a thin adapter over these plans.
|
||||
struct ReminderPlannerTests {
|
||||
|
||||
/// UTC Gregorian so nothing depends on the host machine's timezone.
|
||||
private let cal: Calendar = {
|
||||
var cal = Calendar(identifier: .gregorian)
|
||||
cal.timeZone = TimeZone(identifier: "UTC")!
|
||||
return cal
|
||||
}()
|
||||
|
||||
/// Midnight UTC on an ISO `yyyy-MM-dd` date.
|
||||
private func day(_ iso: String) -> Date {
|
||||
let parts = iso.split(separator: "-").compactMap { Int($0) }
|
||||
var components = DateComponents()
|
||||
components.year = parts[0]; components.month = parts[1]; components.day = parts[2]
|
||||
return cal.date(from: components)!
|
||||
}
|
||||
|
||||
private func source(
|
||||
id: String = "01SCHEDULE0000000000000001",
|
||||
routineName: String = "Push Day",
|
||||
recurrence: ScheduleRecurrence,
|
||||
weekdays: [Int]? = nil,
|
||||
date: Date? = nil,
|
||||
reminderMinutes: Int?
|
||||
) -> ReminderSource {
|
||||
ReminderSource(id: id, routineName: routineName, recurrence: recurrence,
|
||||
weekdays: weekdays, date: date, reminderMinutes: reminderMinutes)
|
||||
}
|
||||
|
||||
// MARK: - Daily
|
||||
|
||||
@Test func dailyProducesOneRepeatingHourMinuteTrigger() throws {
|
||||
let plans = ReminderPlanner.plans(
|
||||
schedules: [source(recurrence: .daily, reminderMinutes: 7 * 60 + 30)],
|
||||
calendar: cal, now: day("2026-07-11"))
|
||||
|
||||
let plan = try #require(plans.first)
|
||||
#expect(plans.count == 1)
|
||||
#expect(plan.identifier == "reminder-01SCHEDULE0000000000000001")
|
||||
#expect(plan.routineName == "Push Day")
|
||||
#expect(plan.repeats)
|
||||
#expect(plan.dateComponents.hour == 7)
|
||||
#expect(plan.dateComponents.minute == 30)
|
||||
// Only hour+minute — no weekday/day, so it fires every day.
|
||||
#expect(plan.dateComponents.weekday == nil)
|
||||
#expect(plan.dateComponents.day == nil)
|
||||
}
|
||||
|
||||
// MARK: - Fixed days
|
||||
|
||||
@Test func fixedDaysProducesOneRepeatingTriggerPerWeekdaySorted() {
|
||||
let plans = ReminderPlanner.plans(
|
||||
schedules: [source(recurrence: .fixedDays, weekdays: [5, 2], reminderMinutes: 18 * 60)],
|
||||
calendar: cal, now: day("2026-07-11"))
|
||||
|
||||
#expect(plans.count == 2)
|
||||
#expect(plans.map(\.identifier) == [
|
||||
"reminder-01SCHEDULE0000000000000001-2",
|
||||
"reminder-01SCHEDULE0000000000000001-5",
|
||||
])
|
||||
#expect(plans.allSatisfy { $0.repeats })
|
||||
#expect(plans.map(\.dateComponents.weekday) == [2, 5])
|
||||
#expect(plans.allSatisfy { $0.dateComponents.hour == 18 && $0.dateComponents.minute == 0 })
|
||||
}
|
||||
|
||||
@Test func fixedDaysIgnoresOutOfRangeWeekdaysAndEmptyListYieldsNothing() {
|
||||
let outOfRange = ReminderPlanner.plans(
|
||||
schedules: [source(recurrence: .fixedDays, weekdays: [0, 3, 9], reminderMinutes: 60)],
|
||||
calendar: cal, now: day("2026-07-11"))
|
||||
#expect(outOfRange.map(\.dateComponents.weekday) == [3])
|
||||
|
||||
let empty = ReminderPlanner.plans(
|
||||
schedules: [source(recurrence: .fixedDays, weekdays: [], reminderMinutes: 60)],
|
||||
calendar: cal, now: day("2026-07-11"))
|
||||
#expect(empty.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Once
|
||||
|
||||
@Test func onceFutureProducesOneNonRepeatingFullDateTrigger() throws {
|
||||
// Reminder at 06:15 on 2026-07-20; "now" is well before it.
|
||||
let plans = ReminderPlanner.plans(
|
||||
schedules: [source(recurrence: .once, date: day("2026-07-20"), reminderMinutes: 6 * 60 + 15)],
|
||||
calendar: cal, now: day("2026-07-11"))
|
||||
|
||||
let plan = try #require(plans.first)
|
||||
#expect(plans.count == 1)
|
||||
#expect(!plan.repeats)
|
||||
#expect(plan.dateComponents.year == 2026)
|
||||
#expect(plan.dateComponents.month == 7)
|
||||
#expect(plan.dateComponents.day == 20)
|
||||
#expect(plan.dateComponents.hour == 6)
|
||||
#expect(plan.dateComponents.minute == 15)
|
||||
}
|
||||
|
||||
@Test func oncePastMomentProducesNothing() {
|
||||
let reminderDay = day("2026-07-11")
|
||||
|
||||
// The whole day is past.
|
||||
let dayPast = ReminderPlanner.plans(
|
||||
schedules: [source(recurrence: .once, date: reminderDay, reminderMinutes: 8 * 60)],
|
||||
calendar: cal, now: day("2026-07-12"))
|
||||
#expect(dayPast.isEmpty)
|
||||
|
||||
// Same day, but the reminder minute (08:00) has already passed by noon.
|
||||
let minutePast = ReminderPlanner.plans(
|
||||
schedules: [source(recurrence: .once, date: reminderDay, reminderMinutes: 8 * 60)],
|
||||
calendar: cal, now: cal.date(byAdding: .hour, value: 12, to: reminderDay)!)
|
||||
#expect(minutePast.isEmpty)
|
||||
|
||||
// Same day, reminder still ahead (20:00 vs noon) — scheduled.
|
||||
let stillAhead = ReminderPlanner.plans(
|
||||
schedules: [source(recurrence: .once, date: reminderDay, reminderMinutes: 20 * 60)],
|
||||
calendar: cal, now: cal.date(byAdding: .hour, value: 12, to: reminderDay)!)
|
||||
#expect(stillAhead.count == 1)
|
||||
}
|
||||
|
||||
@Test func onceWithoutDateProducesNothing() {
|
||||
let plans = ReminderPlanner.plans(
|
||||
schedules: [source(recurrence: .once, date: nil, reminderMinutes: 8 * 60)],
|
||||
calendar: cal, now: day("2026-07-11"))
|
||||
#expect(plans.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - No reminder
|
||||
|
||||
@Test func nilReminderMinutesContributesNothingAcrossAllRecurrences() {
|
||||
let plans = ReminderPlanner.plans(
|
||||
schedules: [
|
||||
source(id: "A", recurrence: .daily, reminderMinutes: nil),
|
||||
source(id: "B", recurrence: .fixedDays, weekdays: [2, 4], reminderMinutes: nil),
|
||||
source(id: "C", recurrence: .once, date: day("2026-08-01"), reminderMinutes: nil),
|
||||
],
|
||||
calendar: cal, now: day("2026-07-11"))
|
||||
#expect(plans.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Mixed batch
|
||||
|
||||
@Test func mixedSchedulesFlattenInInputOrderWithPrefixOnEveryIdentifier() {
|
||||
let plans = ReminderPlanner.plans(
|
||||
schedules: [
|
||||
source(id: "A", recurrence: .daily, reminderMinutes: 6 * 60),
|
||||
source(id: "B", recurrence: .fixedDays, weekdays: [2, 6], reminderMinutes: 17 * 60),
|
||||
source(id: "C", recurrence: .daily, reminderMinutes: nil),
|
||||
],
|
||||
calendar: cal, now: day("2026-07-11"))
|
||||
|
||||
#expect(plans.map(\.identifier) == ["reminder-A", "reminder-B-2", "reminder-B-6"])
|
||||
#expect(plans.allSatisfy { $0.identifier.hasPrefix(ReminderPlanner.identifierPrefix) })
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ struct ScheduleDocumentTests {
|
||||
goal: GoalKind.strength.rawValue,
|
||||
recurrence: ScheduleRecurrence.fixedDays.rawValue,
|
||||
weekdays: [2, 5],
|
||||
reminderMinutes: 7 * 60 + 30,
|
||||
order: 3,
|
||||
createdAt: Self.created,
|
||||
updatedAt: Self.updated
|
||||
@@ -45,6 +46,7 @@ struct ScheduleDocumentTests {
|
||||
#expect(decoded.isReadable)
|
||||
#expect(decoded.goalKind == .strength)
|
||||
#expect(decoded.recurrenceEnum == .fixedDays)
|
||||
#expect(decoded.reminderMinutes == 450)
|
||||
}
|
||||
|
||||
/// The nil-heavy cases round-trip too: a `.daily` schedule with no goal and no
|
||||
@@ -70,6 +72,7 @@ struct ScheduleDocumentTests {
|
||||
#expect(!json.contains("goal"))
|
||||
#expect(!json.contains("weekdays"))
|
||||
#expect(!json.contains("\"date\""))
|
||||
#expect(!json.contains("reminderMinutes"))
|
||||
|
||||
let decoded = try DocumentCoder.decode(ScheduleDocument.self, from: data)
|
||||
#expect(decoded == original)
|
||||
@@ -77,6 +80,7 @@ struct ScheduleDocumentTests {
|
||||
#expect(decoded.goalKind == nil)
|
||||
#expect(decoded.weekdays == nil)
|
||||
#expect(decoded.date == nil)
|
||||
#expect(decoded.reminderMinutes == nil)
|
||||
}
|
||||
|
||||
/// A `.once` schedule carries its one-off day and drops the recurring-only fields.
|
||||
@@ -179,6 +183,7 @@ struct ScheduleDocumentTests {
|
||||
goal: GoalKind.mobility.rawValue,
|
||||
recurrence: ScheduleRecurrence.fixedDays.rawValue,
|
||||
weekdays: [2, 4, 6],
|
||||
reminderMinutes: 18 * 60 + 45,
|
||||
order: 7,
|
||||
createdAt: Self.created,
|
||||
updatedAt: Self.updated
|
||||
@@ -195,6 +200,7 @@ struct ScheduleDocumentTests {
|
||||
#expect(entity.recurrenceRaw == original.recurrence)
|
||||
#expect(entity.recurrenceEnum == .fixedDays)
|
||||
#expect(entity.weekdays == [2, 4, 6])
|
||||
#expect(entity.reminderMinutes == 1125)
|
||||
#expect(entity.order == 7)
|
||||
#expect(entity.jsonRelativePath == "Schedules/\(original.id).json")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user