// ── Routine Engine ──────────────────────────────────────────── // Ordered sequences of timed steps with transitions, state machine, and templates // Ported from web/src/lib/routines.ts import Foundation // MARK: - Types enum TransitionType: String, Codable, CaseIterable, Identifiable { case immediate case oneMinBreak = "1m_break" case fiveMinBreak = "5m_break" case custom var id: String { rawValue } var label: String { switch self { case .immediate: return "Immediate" case .oneMinBreak: return "1 min break" case .fiveMinBreak: return "5 min break" case .custom: return "Custom break" } } var minutes: Int { switch self { case .immediate: return 0 case .oneMinBreak: return 1 case .fiveMinBreak: return 5 case .custom: return 0 } } } enum RoutineStatus: String, Codable { case template case ready case active case paused case completed case cancelled } enum StepStatus: String, Codable { case pending case active case skipped case completed } // MARK: - Routine Step struct CMRoutineStep: Codable, Identifiable, Equatable { let id: String var label: String var durationMinutes: Int var transition: TransitionType var customTransitionMinutes: Int? var notes: String? var status: StepStatus var startedAt: Date? var completedAt: Date? init( id: String = UUID().uuidString, label: String, durationMinutes: Int, transition: TransitionType = .immediate, customTransitionMinutes: Int? = nil, notes: String? = nil, status: StepStatus = .pending, startedAt: Date? = nil, completedAt: Date? = nil ) { self.id = id self.label = label self.durationMinutes = durationMinutes self.transition = transition self.customTransitionMinutes = customTransitionMinutes self.notes = notes self.status = status self.startedAt = startedAt self.completedAt = completedAt } var transitionMinutes: Int { switch transition { case .custom: return customTransitionMinutes ?? 0 default: return transition.minutes } } } // MARK: - Routine struct CMRoutine: Codable, Identifiable, Equatable { let id: String var name: String var routineDescription: String? var steps: [CMRoutineStep] var totalDurationMinutes: Int var status: RoutineStatus var currentStepIndex: Int let createdAt: Date var startedAt: Date? var pausedAt: Date? var completedAt: Date? var elapsedBeforePause: TimeInterval var isTemplate: Bool init( id: String = UUID().uuidString, name: String, routineDescription: String? = nil, steps: [CMRoutineStep], isTemplate: Bool = false ) { self.id = id self.name = name self.routineDescription = routineDescription self.steps = steps self.totalDurationMinutes = CMRoutine.calculateTotalDuration(steps: steps) self.status = isTemplate ? .template : .ready self.currentStepIndex = 0 self.createdAt = Date() self.startedAt = nil self.pausedAt = nil self.completedAt = nil self.elapsedBeforePause = 0 self.isTemplate = isTemplate } static func calculateTotalDuration(steps: [CMRoutineStep]) -> Int { steps.enumerated().reduce(0) { total, pair in let (idx, step) = pair let transition = idx < steps.count - 1 ? step.transitionMinutes : 0 return total + step.durationMinutes + transition } } } // MARK: - State Machine extension CMRoutine { mutating func start() { guard status == .ready || status == .template else { return } guard !steps.isEmpty else { return } let now = Date() for i in steps.indices { steps[i].status = i == 0 ? .active : .pending steps[i].startedAt = i == 0 ? now : nil steps[i].completedAt = nil } status = .active currentStepIndex = 0 startedAt = now pausedAt = nil completedAt = nil elapsedBeforePause = 0 } mutating func pause() { guard status == .active else { return } let now = Date() if let currentStep = currentStep, let stepStart = currentStep.startedAt { elapsedBeforePause += now.timeIntervalSince(stepStart) } status = .paused pausedAt = now } mutating func resume() { guard status == .paused else { return } let now = Date() steps[currentStepIndex].startedAt = now status = .active pausedAt = nil } mutating func completeCurrentStep() { guard status == .active else { return } guard currentStepIndex < steps.count else { return } let now = Date() steps[currentStepIndex].status = .completed steps[currentStepIndex].completedAt = now let nextIndex = currentStepIndex + 1 if nextIndex >= steps.count { status = .completed completedAt = now currentStepIndex = nextIndex elapsedBeforePause = 0 } else { steps[nextIndex].status = .active steps[nextIndex].startedAt = now currentStepIndex = nextIndex elapsedBeforePause = 0 } } mutating func skipCurrentStep() { guard status == .active else { return } guard currentStepIndex < steps.count else { return } let now = Date() steps[currentStepIndex].status = .skipped steps[currentStepIndex].completedAt = now let nextIndex = currentStepIndex + 1 if nextIndex >= steps.count { status = .completed completedAt = now currentStepIndex = nextIndex elapsedBeforePause = 0 } else { steps[nextIndex].status = .active steps[nextIndex].startedAt = now currentStepIndex = nextIndex elapsedBeforePause = 0 } } mutating func cancel() { guard status != .completed && status != .cancelled else { return } status = .cancelled completedAt = Date() } } // MARK: - Utility extension CMRoutine { var currentStep: CMRoutineStep? { guard currentStepIndex < steps.count else { return nil } return steps[currentStepIndex] } var nextStep: CMRoutineStep? { let nextIdx = currentStepIndex + 1 guard nextIdx < steps.count else { return nil } return steps[nextIdx] } var completedStepCount: Int { steps.filter { $0.status == .completed }.count } var skippedStepCount: Int { steps.filter { $0.status == .skipped }.count } var progress: Double { guard !steps.isEmpty else { return 0 } let done = steps.filter { $0.status == .completed || $0.status == .skipped }.count return Double(done) / Double(steps.count) } func remainingStepSeconds(now: Date = Date()) -> TimeInterval { if status == .paused { guard let currentStep = currentStep else { return 0 } return TimeInterval(currentStep.durationMinutes * 60) - elapsedBeforePause } guard status == .active else { return 0 } guard let currentStep = currentStep, let stepStart = currentStep.startedAt else { return 0 } let elapsed = elapsedBeforePause + now.timeIntervalSince(stepStart) return max(0, TimeInterval(currentStep.durationMinutes * 60) - elapsed) } func shouldStepComplete(now: Date = Date()) -> Bool { status == .active && remainingStepSeconds(now: now) <= 0 } func instantiate() -> CMRoutine { guard isTemplate || status == .template else { return self } let newSteps = steps.map { step in CMRoutineStep( label: step.label, durationMinutes: step.durationMinutes, transition: step.transition, customTransitionMinutes: step.customTransitionMinutes, notes: step.notes ) } return CMRoutine(name: name, routineDescription: routineDescription, steps: newSteps) } } // MARK: - Built-in Templates struct RoutineTemplates { static let morning = CMRoutine( name: "Morning Routine", routineDescription: "Start your day with intention", steps: [ CMRoutineStep(label: "Wake Up + Hydrate", durationMinutes: 5, notes: "Drink a glass of water"), CMRoutineStep(label: "Meditation", durationMinutes: 15, transition: .oneMinBreak, notes: "Mindfulness or breathing exercise"), CMRoutineStep(label: "Exercise", durationMinutes: 30, transition: .oneMinBreak, notes: "Workout, yoga, or a walk"), CMRoutineStep(label: "Shower + Get Ready", durationMinutes: 20), CMRoutineStep(label: "Breakfast", durationMinutes: 15), ], isTemplate: true ) static let workout = CMRoutine( name: "Workout", routineDescription: "Structured workout with warm-up and cool-down", steps: [ CMRoutineStep(label: "Warm Up", durationMinutes: 5, notes: "Light stretching and mobility"), CMRoutineStep(label: "Main Workout", durationMinutes: 30, transition: .oneMinBreak, notes: "Strength or cardio"), CMRoutineStep(label: "Cool Down", durationMinutes: 5, notes: "Stretching and foam rolling"), CMRoutineStep(label: "Hydrate + Recovery", durationMinutes: 5), ], isTemplate: true ) static let cookingPrep = CMRoutine( name: "Cooking Prep", routineDescription: "Organized meal preparation", steps: [ CMRoutineStep(label: "Gather Ingredients", durationMinutes: 5, notes: "Get everything out"), CMRoutineStep(label: "Prep & Chop", durationMinutes: 15, notes: "Wash, peel, and chop"), CMRoutineStep(label: "Cook", durationMinutes: 25, transition: .fiveMinBreak, notes: "Follow the recipe"), CMRoutineStep(label: "Plate & Serve", durationMinutes: 5), ], isTemplate: true ) static let eveningWindDown = CMRoutine( name: "Evening Wind-Down", routineDescription: "Prepare your mind and body for rest", steps: [ CMRoutineStep(label: "Screen-Free Time", durationMinutes: 15, notes: "Put devices away"), CMRoutineStep(label: "Light Stretching", durationMinutes: 10, transition: .oneMinBreak, notes: "Gentle yoga or stretching"), CMRoutineStep(label: "Journal / Reflect", durationMinutes: 10, notes: "Write about your day"), CMRoutineStep(label: "Read", durationMinutes: 20, notes: "Read a book or magazine"), ], isTemplate: true ) static let all: [CMRoutine] = [morning, workout, cookingPrep, eveningWindDown] }