- 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
346 lines
11 KiB
Swift
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]
|
|
}
|