diff --git a/ios/ChronoMind/Shared/Reschedule/RescheduleEngine.swift b/ios/ChronoMind/Shared/Reschedule/RescheduleEngine.swift new file mode 100644 index 0000000..a8ebff7 --- /dev/null +++ b/ios/ChronoMind/Shared/Reschedule/RescheduleEngine.swift @@ -0,0 +1,268 @@ +// ── 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)" + } +} diff --git a/ios/ChronoMind/Shared/Store/TimerStore.swift b/ios/ChronoMind/Shared/Store/TimerStore.swift index 4645ab3..40e94a0 100644 --- a/ios/ChronoMind/Shared/Store/TimerStore.swift +++ b/ios/ChronoMind/Shared/Store/TimerStore.swift @@ -97,12 +97,18 @@ final class TimerStore: ObservableObject { func dismiss(_ id: String) { liveActivity.endActivity(for: id) + // Check for late dismissal before updating state + if let timer = getTimer(id) { + checkForRescheduleSuggestion(dismissedTimer: timer) + } updateTimer(id) { dismissTimer($0) } } func complete(_ id: String) { liveActivity.endActivity(for: id) updateTimer(id) { completeTimer($0) } + // Record completion for streak tracking + GamificationStore.shared.recordCompletion() } func advancePom(_ id: String) { @@ -155,6 +161,66 @@ final class TimerStore: ObservableObject { } } + // MARK: - Reschedule + + @Published var lastReschedule: RescheduleResult? + @Published var rescheduleSuggestions: [RescheduleSuggestion] = [] + + /// Apply a reschedule action + func applyReschedule(_ action: RescheduleAction) { + let result = RescheduleEngine.apply(action: action, to: timers) + lastReschedule = result + timers = result.updatedTimers + + // Reschedule notifications for affected timers + for id in result.affectedTimerIds { + if let timer = getTimer(id) { + notifications.scheduleNotifications(for: timer) + } + } + + saveTimers() + + // Auto-clear undo after 1 hour + DispatchQueue.main.asyncAfter(deadline: .now() + 3600) { [weak self] in + if self?.lastReschedule?.timestamp == result.timestamp { + self?.lastReschedule = nil + } + } + } + + /// Undo the last reschedule + func undoReschedule() { + guard let result = lastReschedule else { return } + timers = RescheduleEngine.undo(result: result, in: timers) + + // Reschedule notifications for restored timers + for id in result.affectedTimerIds { + if let timer = getTimer(id) { + notifications.scheduleNotifications(for: timer) + } + } + + lastReschedule = nil + saveTimers() + } + + /// Check for late dismissal and generate suggestions + func checkForRescheduleSuggestion(dismissedTimer: CMTimer) { + let now = Date() + if let delay = RescheduleEngine.detectLateDismissal(timer: dismissedTimer, dismissedAt: now) { + rescheduleSuggestions = RescheduleSuggestion.suggestionsForLateDismissal( + timer: dismissedTimer, + dismissDelay: delay + ) + } + } + + /// Clear current suggestions + func clearRescheduleSuggestions() { + rescheduleSuggestions = [] + } + // MARK: - Queries func getTimer(_ id: String) -> CMTimer? {