- 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
94 lines
3.8 KiB
Swift
94 lines
3.8 KiB
Swift
//
|
||
// 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)]
|
||
}
|
||
}
|
||
}
|