feat(reschedule): add AI reschedule engine with shift, skip, push, undo and smart suggestions
This commit is contained in:
parent
fc05ea12ba
commit
931746a119
268
ios/ChronoMind/Shared/Reschedule/RescheduleEngine.swift
Normal file
268
ios/ChronoMind/Shared/Reschedule/RescheduleEngine.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
@ -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? {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user