406 lines
11 KiB
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
|
|
}
|