learning_ai_clock/ios/ChronoMind/Shared/TimerEngine/Routines.swift
saravanakumardb1 11e50295ea feat: fix web build, add repo infra, port iOS engine modules, add routine screens
- fix(web): cast window through unknown in platform-sync.ts (TS2352)
- docs: add AGENTS.md, README.md, CLAUDE.md, .windsurfrules, .cursorrules, env.example
- feat(ios): port Recurrence.swift from web/src/lib/recurrence.ts
- feat(ios): port NLParser.swift from web/src/lib/nl-parser.ts
- feat(ios): port ContextMessages.swift from web/src/lib/context-messages.ts
- feat(ios): add CMRoutine model + Routines.swift engine with state machine + templates
- feat(ios): add RoutineListView, RoutineRunnerView, RoutineEditorView
- feat(android): add RoutineScreen.kt with list, runner, templates, step controls

Web: 373 tests passing, build succeeds with --webpack flag
2026-02-28 01:50:35 -08:00

346 lines
11 KiB
Swift

// 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]
}