Files
workouts/Workouts/Notifications/ReminderPlanner.swift
T
rzen 5ec5de9295 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
2026-07-11 12:32:08 -04:00

94 lines
3.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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)]
}
}
}