feat(reschedule): add AI reschedule engine with shift, skip, push, undo and smart suggestions

This commit is contained in:
saravanakumardb1 2026-02-27 22:13:27 -08:00
parent fc05ea12ba
commit 931746a119
2 changed files with 334 additions and 0 deletions

View File

@ -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)"
}
}

View File

@ -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? {