// ── Reschedule Engine ───────────────────────────────────────── // AI-style bulk timer rescheduling: shift, skip, push, undo // "I slept in 30 minutes" → shift all morning timers by 30m import Foundation // MARK: - Reschedule Action enum RescheduleAction: Equatable { /// Shift specific timers by a time interval (positive = later, negative = earlier) case shift(timerIds: [String], interval: TimeInterval) /// Shift all active timers by a time interval case shiftAll(interval: TimeInterval) /// Skip (remove) a specific timer and adjust any linked timers case skip(timerId: String) /// Push everything back by an interval case pushAll(interval: TimeInterval) } // MARK: - Reschedule Result struct RescheduleResult { let action: RescheduleAction let affectedTimerIds: [String] let previousStates: [String: CMTimer] // id → original timer for undo let updatedTimers: [CMTimer] let description: String let timestamp: Date } // MARK: - Smart Suggestion struct RescheduleSuggestion: Identifiable { let id = UUID().uuidString let title: String let subtitle: String let icon: String let action: RescheduleAction /// Pre-built suggestions for common scenarios static func suggestionsForLateDismissal(timer: CMTimer, dismissDelay: TimeInterval) -> [RescheduleSuggestion] { let delayMinutes = Int(dismissDelay / 60) guard delayMinutes >= 5 else { return [] } // Round to nearest 5 minutes let roundedMinutes = ((delayMinutes + 2) / 5) * 5 let roundedInterval = TimeInterval(roundedMinutes * 60) var suggestions: [RescheduleSuggestion] = [] // Shift by exact delay suggestions.append(RescheduleSuggestion( title: "Shift by \(roundedMinutes)m", subtitle: "Push all upcoming timers back \(roundedMinutes) minutes", icon: "arrow.right.circle.fill", action: .pushAll(interval: roundedInterval) )) // Half the delay (catch up partially) if roundedMinutes >= 10 { let halfMinutes = roundedMinutes / 2 suggestions.append(RescheduleSuggestion( title: "Shift by \(halfMinutes)m", subtitle: "Partially catch up — shift \(halfMinutes) minutes", icon: "arrow.right.circle", action: .pushAll(interval: TimeInterval(halfMinutes * 60)) )) } // Skip next timer suggestions.append(RescheduleSuggestion( title: "Skip next timer", subtitle: "Remove the next upcoming timer", icon: "forward.fill", action: .skip(timerId: "next") // resolved at apply time )) return suggestions } /// Pre-built quick actions for the reschedule sheet static let quickActions: [RescheduleSuggestion] = [ RescheduleSuggestion( title: "Push 15 minutes", subtitle: "Shift all timers back 15 min", icon: "15.circle.fill", action: .pushAll(interval: 15 * 60) ), RescheduleSuggestion( title: "Push 30 minutes", subtitle: "Shift all timers back 30 min", icon: "30.circle.fill", action: .pushAll(interval: 30 * 60) ), RescheduleSuggestion( title: "Push 1 hour", subtitle: "Shift all timers back 1 hour", icon: "clock.badge.plus", action: .pushAll(interval: 60 * 60) ), RescheduleSuggestion( title: "Pull 15 minutes earlier", subtitle: "Move all timers 15 min earlier", icon: "clock.arrow.2.circlepath", action: .pushAll(interval: -15 * 60) ), ] } // MARK: - Reschedule Engine enum RescheduleEngine { /// Apply a reschedule action to a list of timers static func apply( action: RescheduleAction, to timers: [CMTimer], now: Date = Date() ) -> RescheduleResult { var updated = timers var previousStates: [String: CMTimer] = [:] var affectedIds: [String] = [] var description = "" switch action { case .shift(let timerIds, let interval): for i in updated.indices { if timerIds.contains(updated[i].id) && isReschedulable(updated[i]) { previousStates[updated[i].id] = updated[i] updated[i] = shiftTimer(updated[i], by: interval) affectedIds.append(updated[i].id) } } let minutes = Int(interval / 60) description = "Shifted \(affectedIds.count) timer\(affectedIds.count == 1 ? "" : "s") by \(formatShiftMinutes(minutes))" case .shiftAll(let interval): for i in updated.indices { if isReschedulable(updated[i]) { previousStates[updated[i].id] = updated[i] updated[i] = shiftTimer(updated[i], by: interval) affectedIds.append(updated[i].id) } } let minutes = Int(interval / 60) description = "Shifted all \(affectedIds.count) timer\(affectedIds.count == 1 ? "" : "s") by \(formatShiftMinutes(minutes))" case .skip(let timerId): let targetId: String if timerId == "next" { // Find next active timer targetId = timers .filter { isReschedulable($0) } .sorted { $0.targetTime < $1.targetTime } .first?.id ?? "" } else { targetId = timerId } if let index = updated.firstIndex(where: { $0.id == targetId }) { previousStates[targetId] = updated[index] updated[index] = dismissTimer(updated[index]) affectedIds.append(targetId) description = "Skipped \"\(updated[index].label)\"" } else { description = "No timer to skip" } case .pushAll(let interval): for i in updated.indices { if isReschedulable(updated[i]) && updated[i].targetTime > now { previousStates[updated[i].id] = updated[i] updated[i] = shiftTimer(updated[i], by: interval) affectedIds.append(updated[i].id) } } let minutes = Int(interval / 60) description = "Pushed \(affectedIds.count) timer\(affectedIds.count == 1 ? "" : "s") by \(formatShiftMinutes(minutes))" } return RescheduleResult( action: action, affectedTimerIds: affectedIds, previousStates: previousStates, updatedTimers: updated, description: description, timestamp: now ) } /// Undo a reschedule by restoring previous timer states static func undo( result: RescheduleResult, in timers: [CMTimer] ) -> [CMTimer] { var restored = timers for (id, original) in result.previousStates { if let index = restored.firstIndex(where: { $0.id == id }) { restored[index] = original } } return restored } /// Detect if a timer was dismissed late and calculate delay static func detectLateDismissal(timer: CMTimer, dismissedAt: Date) -> TimeInterval? { guard timer.state == .firing || timer.state == .dismissed else { return nil } let delay = dismissedAt.timeIntervalSince(timer.targetTime) // Only suggest if delay is at least 5 minutes return delay >= 300 ? delay : nil } // MARK: - Private Helpers /// Whether a timer can be rescheduled private static func isReschedulable(_ timer: CMTimer) -> Bool { [.active, .warning, .snoozed, .paused].contains(timer.state) } /// Shift a timer's target time and recalculate warnings private static func shiftTimer(_ timer: CMTimer, by interval: TimeInterval) -> CMTimer { var shifted = timer shifted.targetTime = timer.targetTime.addingTimeInterval(interval) // Shift start time if set if let startedAt = shifted.startedAt { shifted.startedAt = startedAt.addingTimeInterval(interval) } // Shift snoozed until if set if let snoozedUntil = shifted.snoozedUntil { shifted.snoozedUntil = snoozedUntil.addingTimeInterval(interval) } // Recalculate cascade warnings for new target time shifted.warnings = shifted.warnings.map { warning in var w = warning w.scheduledTime = shifted.targetTime.addingTimeInterval(-Double(warning.minutesBefore) * 60) // Reset fired state if warning is now in the future if w.scheduledTime > Date() { w.fired = false w.firedAt = nil } return w } return shifted } /// Format shift minutes as human-readable string private static func formatShiftMinutes(_ minutes: Int) -> String { let absMinutes = abs(minutes) let direction = minutes >= 0 ? "later" : "earlier" if absMinutes >= 60 { let hours = absMinutes / 60 let remainingMins = absMinutes % 60 if remainingMins == 0 { return "\(hours)h \(direction)" } return "\(hours)h \(remainingMins)m \(direction)" } return "\(absMinutes)m \(direction)" } }