// ── Pre-Warning Cascade System ───────────────────────────────── // Cascade presets aligned with PRD: Aggressive, Standard, Light, Minimal, None, Custom // Ported from web/src/lib/cascade.ts import Foundation // MARK: - Cascade Preset enum CascadePreset: String, Codable, CaseIterable, Identifiable { case aggressive case standard case light case minimal case none case custom var id: String { rawValue } var label: String { switch self { case .aggressive: return "Aggressive" case .standard: return "Standard" case .light: return "Light" case .minimal: return "Minimal" case .none: return "None (fire only)" case .custom: return "Custom" } } /// Default intervals in minutes before target time var defaultIntervals: [Int] { switch self { case .aggressive: return [240, 180, 120, 90, 60, 30, 15, 5, 1] case .standard: return [120, 60, 30, 15, 5] case .light: return [60, 15, 5] case .minimal: return [15] case .none: return [] case .custom: return [] } } } // MARK: - Cascade Warning struct CascadeWarning: Codable, Identifiable, Equatable { let id: String let minutesBefore: Int var fired: Bool var firedAt: Date? var scheduledTime: Date static func == (lhs: CascadeWarning, rhs: CascadeWarning) -> Bool { lhs.id == rhs.id } } // MARK: - Cascade Config struct CascadeConfig: Codable { let preset: CascadePreset let intervals: [Int] // minutes before target time (for custom preset) } // MARK: - Cascade Functions /// Calculate all warning timestamps from target time and cascade intervals. /// Filters out warnings that would be in the past relative to `now`. func calculateCascadeWarnings( targetTime: Date, intervals: [Int], now: Date = Date() ) -> [CascadeWarning] { let sorted = intervals.sorted(by: >) // largest first (earliest warning) return sorted.enumerated().map { idx, minutesBefore in let scheduledTime = targetTime.addingTimeInterval(-Double(minutesBefore) * 60.0) return CascadeWarning( id: "w-\(idx)-\(minutesBefore)m", minutesBefore: minutesBefore, fired: scheduledTime <= now, firedAt: scheduledTime <= now ? scheduledTime : nil, scheduledTime: scheduledTime ) } } /// Get the next unfired warning from a cascade. func getNextWarning(_ warnings: [CascadeWarning]) -> CascadeWarning? { warnings.first(where: { !$0.fired }) } /// Check which warnings should fire given the current time. /// Returns newly-fired warning IDs and mutates the warnings array. @discardableResult func checkWarnings(_ warnings: inout [CascadeWarning], now: Date = Date()) -> [String] { var newlyFired: [String] = [] for i in warnings.indices { if !warnings[i].fired && warnings[i].scheduledTime <= now { warnings[i].fired = true warnings[i].firedAt = now newlyFired.append(warnings[i].id) } } return newlyFired } /// Get intervals for a preset, or custom intervals if preset is 'custom'. func getCascadeIntervals(_ config: CascadeConfig) -> [Int] { if config.preset == .custom { return config.intervals.sorted(by: >) } return config.preset.defaultIntervals } /// Format minutes into human-readable string. func formatMinutesBefore(_ minutes: Int) -> String { if minutes >= 60 { let hours = minutes / 60 let remaining = minutes % 60 if remaining == 0 { return "\(hours)h" } return "\(hours)h \(remaining)m" } return "\(minutes)m" }