269 lines
9.5 KiB
Swift
269 lines
9.5 KiB
Swift
// ── 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)"
|
|
}
|
|
}
|