learning_ai_clock/ios/ChronoMind/Shared/TimerEngine/TimerEngine.swift

406 lines
11 KiB
Swift

// Timer Engine
// Core timer types, state machine, and lifecycle management
// Ported from web/src/lib/timer-engine.ts
import Foundation
// MARK: - Timer Types
enum CMTimerType: String, Codable, CaseIterable, Identifiable {
case alarm
case countdown
case pomodoro
case event
var id: String { rawValue }
var label: String {
switch self {
case .alarm: return "Alarm"
case .countdown: return "Countdown"
case .pomodoro: return "Pomodoro"
case .event: return "Event"
}
}
}
// MARK: - Timer State
enum CMTimerState: String, Codable {
case idle
case active
case warning
case firing
case snoozed
case dismissed
case completed
case paused
}
// MARK: - Pomodoro Config
struct PomodoroConfig: Codable, Equatable {
var workMinutes: Int
var breakMinutes: Int
var longBreakMinutes: Int
var rounds: Int
static let `default` = PomodoroConfig(
workMinutes: 25,
breakMinutes: 5,
longBreakMinutes: 15,
rounds: 4
)
}
// MARK: - Pomodoro State
struct PomodoroState: Codable {
var currentRound: Int
var isBreak: Bool
var isLongBreak: Bool
var completedRounds: Int
}
// MARK: - Timer Model
struct CMTimer: Codable, Identifiable, Equatable {
let id: String
let type: CMTimerType
var label: String
var description: String?
var urgency: UrgencyLevel
var state: CMTimerState
// Time fields
var targetTime: Date // when the timer fires
var duration: TimeInterval? // seconds for countdowns/pomodoro
let createdAt: Date
var startedAt: Date?
var pausedAt: Date?
var firedAt: Date?
var dismissedAt: Date?
var completedAt: Date?
// Elapsed tracking for pause/resume
var elapsedBeforePause: TimeInterval // seconds accumulated before last pause
// Cascade
var cascade: CascadeConfig
var warnings: [CascadeWarning]
// Pomodoro-specific
var pomodoroConfig: PomodoroConfig?
var pomodoroState: PomodoroState?
// Snooze
var snoozeCount: Int
var snoozedUntil: Date?
// Metadata
var category: String?
var tags: [String]?
var linkedTimerId: String?
static func == (lhs: CMTimer, rhs: CMTimer) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Factory Functions
struct CreateAlarmParams {
let label: String
let targetTime: Date
var urgency: UrgencyLevel = .standard
var cascade: CascadeConfig? = nil
var category: String? = nil
var description: String? = nil
}
func createAlarm(_ params: CreateAlarmParams) -> CMTimer {
let cascade = params.cascade ?? CascadeConfig(preset: .standard, intervals: [])
let intervals = getCascadeIntervals(cascade)
let now = Date()
return CMTimer(
id: UUID().uuidString,
type: .alarm,
label: params.label,
description: params.description,
urgency: params.urgency,
state: .active,
targetTime: params.targetTime,
duration: params.targetTime.timeIntervalSince(now),
createdAt: now,
startedAt: now,
pausedAt: nil,
firedAt: nil,
dismissedAt: nil,
completedAt: nil,
elapsedBeforePause: 0,
cascade: cascade,
warnings: calculateCascadeWarnings(targetTime: params.targetTime, intervals: intervals, now: now),
pomodoroConfig: nil,
pomodoroState: nil,
snoozeCount: 0,
snoozedUntil: nil,
category: params.category,
tags: nil,
linkedTimerId: nil
)
}
struct CreateCountdownParams {
let label: String
let durationSeconds: TimeInterval
var urgency: UrgencyLevel = .standard
var cascade: CascadeConfig? = nil
var category: String? = nil
var description: String? = nil
}
func createCountdown(_ params: CreateCountdownParams) -> CMTimer {
let now = Date()
let targetTime = now.addingTimeInterval(params.durationSeconds)
let cascade = params.cascade ?? CascadeConfig(preset: .standard, intervals: [])
let intervals = getCascadeIntervals(cascade)
return CMTimer(
id: UUID().uuidString,
type: .countdown,
label: params.label,
description: params.description,
urgency: params.urgency,
state: .active,
targetTime: targetTime,
duration: params.durationSeconds,
createdAt: now,
startedAt: now,
pausedAt: nil,
firedAt: nil,
dismissedAt: nil,
completedAt: nil,
elapsedBeforePause: 0,
cascade: cascade,
warnings: calculateCascadeWarnings(targetTime: targetTime, intervals: intervals, now: now),
pomodoroConfig: nil,
pomodoroState: nil,
snoozeCount: 0,
snoozedUntil: nil,
category: params.category,
tags: nil,
linkedTimerId: nil
)
}
struct CreatePomodoroParams {
var label: String = "Focus Session"
var config: PomodoroConfig = .default
var urgency: UrgencyLevel = .standard
}
func createPomodoro(_ params: CreatePomodoroParams = CreatePomodoroParams()) -> CMTimer {
let now = Date()
let durationSeconds = TimeInterval(params.config.workMinutes * 60)
let targetTime = now.addingTimeInterval(durationSeconds)
return CMTimer(
id: UUID().uuidString,
type: .pomodoro,
label: params.label,
description: nil,
urgency: params.urgency,
state: .active,
targetTime: targetTime,
duration: durationSeconds,
createdAt: now,
startedAt: now,
pausedAt: nil,
firedAt: nil,
dismissedAt: nil,
completedAt: nil,
elapsedBeforePause: 0,
cascade: CascadeConfig(preset: .minimal, intervals: []),
warnings: calculateCascadeWarnings(targetTime: targetTime, intervals: [1], now: now),
pomodoroConfig: params.config,
pomodoroState: PomodoroState(
currentRound: 1,
isBreak: false,
isLongBreak: false,
completedRounds: 0
),
snoozeCount: 0,
snoozedUntil: nil,
category: nil,
tags: nil,
linkedTimerId: nil
)
}
// MARK: - State Transitions
func pauseTimer(_ timer: CMTimer) -> CMTimer {
guard timer.state == .active || timer.state == .warning else { return timer }
var t = timer
let now = Date()
let elapsed = t.elapsedBeforePause + now.timeIntervalSince(t.startedAt ?? now)
t.state = .paused
t.pausedAt = now
t.elapsedBeforePause = elapsed
return t
}
func resumeTimer(_ timer: CMTimer) -> CMTimer {
guard timer.state == .paused else { return timer }
var t = timer
let now = Date()
let remainingSeconds = (t.duration ?? 0) - t.elapsedBeforePause
let newTargetTime = now.addingTimeInterval(remainingSeconds)
let intervals = getCascadeIntervals(t.cascade)
t.state = .active
t.startedAt = now
t.pausedAt = nil
t.targetTime = newTargetTime
t.warnings = calculateCascadeWarnings(targetTime: newTargetTime, intervals: intervals, now: now)
return t
}
func fireTimer(_ timer: CMTimer) -> CMTimer {
guard timer.state != .dismissed && timer.state != .completed else { return timer }
var t = timer
t.state = .firing
t.firedAt = Date()
return t
}
func snoozeTimer(_ timer: CMTimer, snoozeMinutes: Int) -> CMTimer {
guard timer.state == .firing else { return timer }
var t = timer
let now = Date()
let snoozeUntil = now.addingTimeInterval(TimeInterval(snoozeMinutes * 60))
let intervals = getCascadeIntervals(t.cascade).filter { $0 <= snoozeMinutes }
t.state = .snoozed
t.targetTime = snoozeUntil
t.snoozedUntil = snoozeUntil
t.snoozeCount += 1
t.warnings = calculateCascadeWarnings(targetTime: snoozeUntil, intervals: intervals, now: now)
return t
}
func dismissTimer(_ timer: CMTimer) -> CMTimer {
var t = timer
t.state = .dismissed
t.dismissedAt = Date()
return t
}
func completeTimer(_ timer: CMTimer) -> CMTimer {
var t = timer
t.state = .completed
t.completedAt = Date()
return t
}
// MARK: - Pomodoro Transitions
func advancePomodoro(_ timer: CMTimer) -> CMTimer? {
guard timer.type == .pomodoro,
let config = timer.pomodoroConfig,
let state = timer.pomodoroState else { return nil }
var t = timer
let now = Date()
if state.isBreak || state.isLongBreak {
// Long break finished all done
if state.isLongBreak {
return completeTimer(t)
}
// Short break finished start next work round
let nextRound = state.currentRound + 1
if nextRound > config.rounds {
return completeTimer(t)
}
let durationSeconds = TimeInterval(config.workMinutes * 60)
t.state = .active
t.targetTime = now.addingTimeInterval(durationSeconds)
t.duration = durationSeconds
t.startedAt = now
t.firedAt = nil
t.elapsedBeforePause = 0
t.warnings = calculateCascadeWarnings(targetTime: t.targetTime, intervals: [1], now: now)
t.pomodoroState = PomodoroState(
currentRound: nextRound,
isBreak: false,
isLongBreak: false,
completedRounds: state.completedRounds
)
return t
} else {
// Work finished start break
let completedRounds = state.completedRounds + 1
let isLongBreak = completedRounds >= config.rounds
if isLongBreak {
// All rounds done, long break
let durationSeconds = TimeInterval(config.longBreakMinutes * 60)
t.state = .active
t.targetTime = now.addingTimeInterval(durationSeconds)
t.duration = durationSeconds
t.startedAt = now
t.firedAt = nil
t.elapsedBeforePause = 0
t.warnings = []
t.pomodoroState = PomodoroState(
currentRound: state.currentRound,
isBreak: false,
isLongBreak: true,
completedRounds: completedRounds
)
return t
}
let durationSeconds = TimeInterval(config.breakMinutes * 60)
t.state = .active
t.targetTime = now.addingTimeInterval(durationSeconds)
t.duration = durationSeconds
t.startedAt = now
t.firedAt = nil
t.elapsedBeforePause = 0
t.warnings = []
t.pomodoroState = PomodoroState(
currentRound: state.currentRound,
isBreak: true,
isLongBreak: false,
completedRounds: completedRounds
)
return t
}
}
// MARK: - Utility
func getRemainingSeconds(_ timer: CMTimer, now: Date = Date()) -> TimeInterval {
if timer.state == .paused {
return (timer.duration ?? 0) - timer.elapsedBeforePause
}
return max(0, timer.targetTime.timeIntervalSince(now))
}
func isTimerActive(_ timer: CMTimer) -> Bool {
[.active, .warning, .snoozed].contains(timer.state)
}
func shouldTimerFire(_ timer: CMTimer, now: Date = Date()) -> Bool {
if timer.state == .snoozed, let snoozedUntil = timer.snoozedUntil, now >= snoozedUntil {
return true
}
if (timer.state == .active || timer.state == .warning) && now >= timer.targetTime {
return true
}
return false
}