From 5ec5de92955d33974cc21cb48bf8f9ed9fb98fcb Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 11 Jul 2026 12:32:08 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 2 + Shared/Model/Documents.swift | 4 + Shared/Model/Entities.swift | 5 +- Shared/Model/Mappers.swift | 5 +- Workouts/AppServices.swift | 15 ++ Workouts/Notifications/ReminderPlanner.swift | 93 ++++++++++ .../Notifications/ReminderScheduler.swift | 107 ++++++++++++ .../Views/Today/ScheduleAddEditView.swift | 47 ++++++ WorkoutsTests/ReminderPlannerTests.swift | 159 ++++++++++++++++++ WorkoutsTests/ScheduleDocumentTests.swift | 6 + 10 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 Workouts/Notifications/ReminderPlanner.swift create mode 100644 Workouts/Notifications/ReminderScheduler.swift create mode 100644 WorkoutsTests/ReminderPlannerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1ecfd..38490f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **July 2026** +Scheduled workouts can now remind you with a notification at a time you choose. + The Today board now shows just the day's docket — only schedules actually due on the viewed day appear. Adding from the Today board is now New Workout, with a Now option that starts a workout immediately instead of creating a schedule. diff --git a/Shared/Model/Documents.swift b/Shared/Model/Documents.swift index 77ad4da..d5222ad 100644 --- a/Shared/Model/Documents.swift +++ b/Shared/Model/Documents.swift @@ -377,6 +377,10 @@ struct ScheduleDocument: Codable, Sendable, Equatable, Identifiable { var recurrence: String // ScheduleRecurrence raw value var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) – 7 (Sat); .fixedDays only var date: Date? = nil // the one-off day; .once only + /// Reminder time as minutes from midnight (local wall-clock, 0–1439); nil = no + /// reminder. Optional addition — decode-compatible, deliberately NOT schema-bumped + /// (an older app dropping it on rewrite loses only a notification preference). + var reminderMinutes: Int? = nil var order: Int var createdAt: Date var updatedAt: Date diff --git a/Shared/Model/Entities.swift b/Shared/Model/Entities.swift index 87892ca..f58db9d 100644 --- a/Shared/Model/Entities.swift +++ b/Shared/Model/Entities.swift @@ -318,13 +318,15 @@ final class Schedule { var recurrenceRaw: String = ScheduleRecurrence.daily.rawValue var weekdays: [Int]? var date: Date? + /// Reminder time as minutes from midnight (local wall-clock); nil = no reminder. + var reminderMinutes: Int? var order: Int = 0 var createdAt: Date = Date() var updatedAt: Date = Date() var jsonRelativePath: String = "" init(id: String, routineID: String, routineName: String, goalRaw: String?, - recurrenceRaw: String, weekdays: [Int]?, date: Date? = nil, + recurrenceRaw: String, weekdays: [Int]?, date: Date? = nil, reminderMinutes: Int? = nil, order: Int, createdAt: Date, updatedAt: Date, jsonRelativePath: String) { self.id = id self.routineID = routineID @@ -333,6 +335,7 @@ final class Schedule { self.recurrenceRaw = recurrenceRaw self.weekdays = weekdays self.date = date + self.reminderMinutes = reminderMinutes self.order = order self.createdAt = createdAt self.updatedAt = updatedAt diff --git a/Shared/Model/Mappers.swift b/Shared/Model/Mappers.swift index 5189548..d458c64 100644 --- a/Shared/Model/Mappers.swift +++ b/Shared/Model/Mappers.swift @@ -92,7 +92,7 @@ extension ScheduleDocument { routineID: schedule.routineID, routineName: schedule.routineName, goal: schedule.goalRaw, recurrence: schedule.recurrenceRaw, weekdays: schedule.weekdays, - date: schedule.date, + date: schedule.date, reminderMinutes: schedule.reminderMinutes, order: schedule.order, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt) } } @@ -255,7 +255,7 @@ enum CacheMapper { } else { schedule = Schedule(id: doc.id, routineID: doc.routineID, routineName: doc.routineName, goalRaw: doc.goal, recurrenceRaw: doc.recurrence, weekdays: doc.weekdays, - date: doc.date, order: doc.order, + date: doc.date, reminderMinutes: doc.reminderMinutes, order: doc.order, createdAt: doc.createdAt, updatedAt: doc.updatedAt, jsonRelativePath: relativePath) context.insert(schedule) @@ -266,6 +266,7 @@ enum CacheMapper { schedule.recurrenceRaw = doc.recurrence schedule.weekdays = doc.weekdays schedule.date = doc.date + schedule.reminderMinutes = doc.reminderMinutes schedule.order = doc.order schedule.createdAt = doc.createdAt schedule.updatedAt = doc.updatedAt diff --git a/Workouts/AppServices.swift b/Workouts/AppServices.swift index 11221d3..029c541 100644 --- a/Workouts/AppServices.swift +++ b/Workouts/AppServices.swift @@ -19,6 +19,10 @@ final class AppServices { let workoutLauncher = WorkoutLauncher() let workoutHealthDeleter = WorkoutHealthDeleter() + /// Keeps pending local notifications in sync with the schedules' reminder times + /// (an idempotent full resync on every cache change). + let reminderScheduler: ReminderScheduler + /// Speaks exercise instructions aloud (library Speak button + hands-free workout cues). /// Shared so a new exercise's cue cancels whatever the last screen was still saying. let speechAnnouncer = SpeechAnnouncer() @@ -43,6 +47,7 @@ final class AppServices { let liveRunState = LiveRunState() self.liveRunState = liveRunState self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, liveRunState: liveRunState) + self.reminderScheduler = ReminderScheduler(container: container) // Launch the wrist session only when a run actually begins (the first // exercise leaves `.notStarted` on the phone) — creating a workout alone is // just a peek and must not start anything on the watch. Watch-originated @@ -70,6 +75,16 @@ final class AppServices { await self.liveActivity.endStaleActivities() await self.syncEngine.connect() self.watchBridge.activate() + // `activate()` just claimed `onCacheChanged` for the watch push — multiplex + // rather than replace, so reminder reconciliation rides the same signal. + let watchPush = self.syncEngine.onCacheChanged + self.syncEngine.onCacheChanged = { [weak self] in + watchPush?() + self?.reminderScheduler.scheduleReconcile() + } + // Initial post-bootstrap resync (covers changes that happened while + // the app wasn't running — e.g. schedules edited on another device). + self.reminderScheduler.scheduleReconcile() // Past the iCloud gate: request the workout-share scope so the user can still // delete legacy phone-estimate workouts from Health when deleting them here. self.workoutHealthDeleter.authorizeIfNeeded() diff --git a/Workouts/Notifications/ReminderPlanner.swift b/Workouts/Notifications/ReminderPlanner.swift new file mode 100644 index 0000000..8057bb7 --- /dev/null +++ b/Workouts/Notifications/ReminderPlanner.swift @@ -0,0 +1,93 @@ +// +// 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)] + } + } +} diff --git a/Workouts/Notifications/ReminderScheduler.swift b/Workouts/Notifications/ReminderScheduler.swift new file mode 100644 index 0000000..e76bf11 --- /dev/null +++ b/Workouts/Notifications/ReminderScheduler.swift @@ -0,0 +1,107 @@ +// +// ReminderScheduler.swift +// Workouts +// +// Copyright 2026 Rouslan Zenetl. All Rights Reserved. +// + +import Foundation +import SwiftData +import UserNotifications +import OSLog + +/// Keeps pending local notifications in sync with the schedules' reminder times. +/// `reconcile()` is an idempotent full resync: it wipes every pending request with +/// the app's `reminder-` identifier prefix and re-adds from the current cache — so +/// deleted schedules drop out naturally, and repeated calls are harmless. Trigger +/// math lives in the pure `ReminderPlanner`. +@MainActor +final class ReminderScheduler { + private let container: ModelContainer + private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "reminders") + private var reconcileTask: Task? + + init(container: ModelContainer) { + self.container = container + } + + /// Coalesced entry point for cache-change storms: cancels any not-yet-run + /// reconcile and schedules a fresh one after a short beat. + func scheduleReconcile() { + reconcileTask?.cancel() + reconcileTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + await self?.reconcile() + } + } + + /// Full resync of pending reminder notifications against the schedule cache. + func reconcile() async { + let plans = ReminderPlanner.plans(schedules: currentSources()) + let center = UNUserNotificationCenter.current() + + // Wipe-then-add: remove exactly our requests, then re-add the current set. + let pending = await center.pendingNotificationRequests() + let stale = pending.map(\.identifier).filter { $0.hasPrefix(ReminderPlanner.identifierPrefix) } + if !stale.isEmpty { + center.removePendingNotificationRequests(withIdentifiers: stale) + } + + guard !plans.isEmpty else { return } + + // Ask permission lazily — only once there is actually something to remind + // about, so users who never set reminders never see the prompt. + guard await requestAuthorizationIfNeeded() else { + log.info("reconcile: notifications not authorized — \(plans.count) reminder(s) not scheduled") + return + } + + for plan in plans { + let content = UNMutableNotificationContent() + content.title = plan.routineName + content.body = "Scheduled workout — time to train." + content.sound = .default + let trigger = UNCalendarNotificationTrigger(dateMatching: plan.dateComponents, + repeats: plan.repeats) + let request = UNNotificationRequest(identifier: plan.identifier, + content: content, trigger: trigger) + do { + try await center.add(request) + } catch { + log.error("reconcile: failed to add \(plan.identifier): \(error.localizedDescription)") + } + } + log.info("reconcile: scheduled \(plans.count) reminder request(s)") + } + + /// Requests `.alert`/`.sound` authorization when it has never been asked for; + /// returns whether reminders can be delivered afterward. + func requestAuthorizationIfNeeded() async -> Bool { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .notDetermined: + return (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false + case .authorized, .provisional, .ephemeral: + return true + case .denied: + return false + @unknown default: + return false + } + } + + /// Snapshot the schedule cache into plain planner inputs. + private func currentSources() -> [ReminderSource] { + let schedules = (try? container.mainContext.fetch(FetchDescriptor())) ?? [] + return schedules.map { schedule in + ReminderSource(id: schedule.id, + routineName: schedule.routineName, + recurrence: schedule.recurrenceEnum, + weekdays: schedule.weekdays, + date: schedule.date, + reminderMinutes: schedule.reminderMinutes) + } + } +} diff --git a/Workouts/Views/Today/ScheduleAddEditView.swift b/Workouts/Views/Today/ScheduleAddEditView.swift index bcdb18d..f5ad674 100644 --- a/Workouts/Views/Today/ScheduleAddEditView.swift +++ b/Workouts/Views/Today/ScheduleAddEditView.swift @@ -8,6 +8,7 @@ import IndieSync import SwiftUI import SwiftData +import UserNotifications /// The UI-only "When" choice — a superset of `ScheduleRecurrence` with one extra /// option, `.now`, that never gets persisted: it skips the schedule document @@ -68,6 +69,12 @@ struct ScheduleAddEditView: View { @State private var when: WhenChoice @State private var weekdays: Set @State private var date: Date + @State private var reminderEnabled: Bool + @State private var reminderTime: Date + /// Notification permission has been explicitly denied — surfaced as a footer so + /// the user knows reminders won't arrive until re-enabled in Settings. Checked + /// async on appear; never blocks the form. + @State private var notificationsDenied = false /// "Now" starts a workout immediately instead of saving a schedule — only offered /// when adding (not editing) for today's default date, since a past/future day @@ -95,6 +102,8 @@ struct ScheduleAddEditView: View { _goal = State(initialValue: schedule?.goalKind) _weekdays = State(initialValue: Set(schedule?.weekdays ?? [])) _date = State(initialValue: schedule?.date ?? defaultDate) + _reminderEnabled = State(initialValue: schedule?.reminderMinutes != nil) + _reminderTime = State(initialValue: Self.time(fromMinutes: schedule?.reminderMinutes)) self.onStartNow = onStartNow let offersNow = schedule == nil && defaultDate.isSameDay(as: Date()) @@ -131,6 +140,7 @@ struct ScheduleAddEditView: View { routineSection if when != .now { goalSection + reminderSection } } .navigationTitle(isEditing ? "Edit Workout" : "New Workout") @@ -148,6 +158,11 @@ struct ScheduleAddEditView: View { // Default to the first routine when adding (the @Query isn't populated at init). if routineID.isEmpty { routineID = routines.first?.id ?? "" } } + .task { + // Surface a denied permission as a footer note — informational only. + let settings = await UNUserNotificationCenter.current().notificationSettings() + notificationsDenied = settings.authorizationStatus == .denied + } } } @@ -221,6 +236,21 @@ struct ScheduleAddEditView: View { } } + private var reminderSection: some View { + Section { + Toggle("Remind Me", isOn: $reminderEnabled) + if reminderEnabled { + DatePicker("Time", selection: $reminderTime, displayedComponents: .hourAndMinute) + } + } header: { + Text("Reminder") + } footer: { + if reminderEnabled && notificationsDenied { + Text("Notifications are turned off for Workouts. Reminders won't arrive until you enable them in Settings.") + } + } + } + /// A row of toggleable weekday chips, Monday-first. private var weekdayPicker: some View { HStack(spacing: 6) { @@ -264,6 +294,7 @@ struct ScheduleAddEditView: View { recurrence: recurrence.rawValue, weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil, date: recurrence == .once ? date : nil, + reminderMinutes: reminderEnabled ? Self.minutesFromMidnight(of: reminderTime) : nil, order: isEditing ? order : nextOrder(), createdAt: createdAt, updatedAt: Date() @@ -277,6 +308,22 @@ struct ScheduleAddEditView: View { let existing = (try? modelContext.fetch(FetchDescriptor())) ?? [] return (existing.map(\.order).max() ?? -1) + 1 } + + // MARK: - Reminder time conversion + + /// A Date (today, local calendar) at the stored minutes-from-midnight, for the + /// hour-and-minute picker. Nil (no reminder yet) seeds a sensible 8:00 AM default. + private static func time(fromMinutes minutes: Int?) -> Date { + let startOfDay = Calendar.current.startOfDay(for: Date()) + return Calendar.current.date(byAdding: .minute, value: minutes ?? 8 * 60, to: startOfDay) + ?? startOfDay + } + + /// The picked time back to minutes from midnight (local wall-clock). + private static func minutesFromMidnight(of time: Date) -> Int { + let comps = Calendar.current.dateComponents([.hour, .minute], from: time) + return (comps.hour ?? 0) * 60 + (comps.minute ?? 0) + } } // MARK: - Routine picker diff --git a/WorkoutsTests/ReminderPlannerTests.swift b/WorkoutsTests/ReminderPlannerTests.swift new file mode 100644 index 0000000..eb3050f --- /dev/null +++ b/WorkoutsTests/ReminderPlannerTests.swift @@ -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) }) + } +} diff --git a/WorkoutsTests/ScheduleDocumentTests.swift b/WorkoutsTests/ScheduleDocumentTests.swift index 9b0a7d7..61af6be 100644 --- a/WorkoutsTests/ScheduleDocumentTests.swift +++ b/WorkoutsTests/ScheduleDocumentTests.swift @@ -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")