// // ReminderPlanner.swift // Workouts // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import Foundation /// A plain snapshot of the schedule fields the reminder planner needs (precedent: /// `ScheduleFacts`). Built from `Schedule` cache entities by the scheduler. struct ReminderSource: Sendable, Equatable { var id: String var routineName: String var recurrence: ScheduleRecurrence var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) – 7 (Sat); .fixedDays only var date: Date? // the one-off day; .once only var reminderMinutes: Int? // minutes from midnight, local wall-clock; nil = no reminder } /// One notification request to schedule: a stable identifier (all carrying the /// `reminder-` prefix so a resync can wipe exactly ours), the trigger's date /// components, and whether it repeats. struct ReminderPlan: Sendable, Equatable { var identifier: String var routineName: String var dateComponents: DateComponents var repeats: Bool } /// Pure derivation of notification plans from schedule snapshots (precedent: /// `SeedReconcilePlanner` / `ProgressPlanner`) — the scheduler consumes its output, /// so the trigger math is unit-testable without touching `UNUserNotificationCenter`. enum ReminderPlanner { /// Every reminder identifier starts with this, so a full resync can remove all /// pending reminder requests (and nothing else) before re-adding. static let identifierPrefix = "reminder-" /// The plans for the given schedules. Schedules with no `reminderMinutes` /// contribute nothing; a `.once` schedule whose reminder moment is already past /// (or that has no date) contributes nothing. static func plans( schedules: [ReminderSource], calendar: Calendar = .current, now: Date = Date() ) -> [ReminderPlan] { schedules.flatMap { plans(for: $0, calendar: calendar, now: now) } } private static func plans( for schedule: ReminderSource, calendar: Calendar, now: Date ) -> [ReminderPlan] { guard let minutes = schedule.reminderMinutes else { return [] } let hour = minutes / 60 let minute = minutes % 60 switch schedule.recurrence { case .daily: var comps = DateComponents() comps.hour = hour comps.minute = minute return [ReminderPlan(identifier: identifierPrefix + schedule.id, routineName: schedule.routineName, dateComponents: comps, repeats: true)] case .fixedDays: return (schedule.weekdays ?? []) .filter { (1...7).contains($0) } .sorted() .map { weekday in var comps = DateComponents() comps.weekday = weekday comps.hour = hour comps.minute = minute return ReminderPlan(identifier: "\(identifierPrefix)\(schedule.id)-\(weekday)", routineName: schedule.routineName, dateComponents: comps, repeats: true) } case .once: guard let day = schedule.date else { return [] } let startOfDay = calendar.startOfDay(for: day) guard let fireDate = calendar.date(byAdding: .minute, value: minutes, to: startOfDay), fireDate > now else { return [] } let comps = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate) return [ReminderPlan(identifier: identifierPrefix + schedule.id, routineName: schedule.routineName, dateComponents: comps, repeats: false)] } } }