diff --git a/ios/ChronoMind/Shared/TimerEngine/Cascade.swift b/ios/ChronoMind/Shared/TimerEngine/Cascade.swift new file mode 100644 index 0000000..abc2394 --- /dev/null +++ b/ios/ChronoMind/Shared/TimerEngine/Cascade.swift @@ -0,0 +1,123 @@ +// ── 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? + let 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" +} diff --git a/ios/ChronoMind/Shared/TimerEngine/Urgency.swift b/ios/ChronoMind/Shared/TimerEngine/Urgency.swift new file mode 100644 index 0000000..5630227 --- /dev/null +++ b/ios/ChronoMind/Shared/TimerEngine/Urgency.swift @@ -0,0 +1,137 @@ +// ── Urgency System ───────────────────────────────────────────── +// 5 levels mapping to notification style, sound, vibration, visual intensity, snooze behavior +// Ported from web/src/lib/urgency.ts + +import SwiftUI + +// MARK: - Urgency Level + +enum UrgencyLevel: String, Codable, CaseIterable, Identifiable { + case critical + case important + case standard + case gentle + case passive + + var id: String { rawValue } +} + +// MARK: - Notification Style + +enum NotificationStyle: String, Codable { + case persistent // Critical: won't auto-dismiss + case prominent // Important: sound + banner + case `default` // Standard: normal notification + case subtle // Gentle: silent banner + case badge // Passive: badge only +} + +// MARK: - Urgency Configuration + +struct UrgencyConfig { + let level: UrgencyLevel + let label: String + let color: Color + let colorHex: String + let notificationStyle: NotificationStyle + let soundEnabled: Bool + let vibrationPattern: [Int] // ms on/off pattern + let visualIntensity: Double // 0-1 + let autoSnoozeMinutes: Int? // nil = no auto-snooze + let requireConfirmToDismiss: Bool + let fullScreenOverlay: Bool +} + +// MARK: - Urgency Configs + +let urgencyConfigs: [UrgencyLevel: UrgencyConfig] = [ + .critical: UrgencyConfig( + level: .critical, + label: "Critical", + color: Color(hex: 0xFF4757), + colorHex: "#FF4757", + notificationStyle: .persistent, + soundEnabled: true, + vibrationPattern: [200, 100, 200, 100, 400], + visualIntensity: 1.0, + autoSnoozeMinutes: nil, + requireConfirmToDismiss: true, + fullScreenOverlay: true + ), + .important: UrgencyConfig( + level: .important, + label: "Important", + color: Color(hex: 0xFF9F43), + colorHex: "#FF9F43", + notificationStyle: .prominent, + soundEnabled: true, + vibrationPattern: [200, 100, 200], + visualIntensity: 0.8, + autoSnoozeMinutes: 10, + requireConfirmToDismiss: false, + fullScreenOverlay: false + ), + .standard: UrgencyConfig( + level: .standard, + label: "Standard", + color: Color(hex: 0xFECA57), + colorHex: "#FECA57", + notificationStyle: .default, + soundEnabled: true, + vibrationPattern: [200], + visualIntensity: 0.6, + autoSnoozeMinutes: 5, + requireConfirmToDismiss: false, + fullScreenOverlay: false + ), + .gentle: UrgencyConfig( + level: .gentle, + label: "Gentle", + color: Color(hex: 0x2ED573), + colorHex: "#2ED573", + notificationStyle: .subtle, + soundEnabled: true, + vibrationPattern: [100], + visualIntensity: 0.3, + autoSnoozeMinutes: 5, + requireConfirmToDismiss: false, + fullScreenOverlay: false + ), + .passive: UrgencyConfig( + level: .passive, + label: "Passive", + color: Color(hex: 0xA5B1C7), + colorHex: "#A5B1C7", + notificationStyle: .badge, + soundEnabled: false, + vibrationPattern: [], + visualIntensity: 0.1, + autoSnoozeMinutes: nil, + requireConfirmToDismiss: false, + fullScreenOverlay: false + ), +] + +let urgencyOrder: [UrgencyLevel] = [.critical, .important, .standard, .gentle, .passive] + +func getUrgencyConfig(_ level: UrgencyLevel) -> UrgencyConfig { + urgencyConfigs[level]! +} + +func getUrgencyIndex(_ level: UrgencyLevel) -> Int { + urgencyOrder.firstIndex(of: level) ?? 0 +} + +// MARK: - Color Extension + +extension Color { + init(hex: UInt, alpha: Double = 1.0) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255.0, + green: Double((hex >> 8) & 0xFF) / 255.0, + blue: Double(hex & 0xFF) / 255.0, + opacity: alpha + ) + } +} diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index 602c3ab..854f379 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -12,6 +12,7 @@ import { AlarmOverlay } from './AlarmOverlay'; import { requestNotificationPermission } from '@/lib/notifications'; import { formatTime, formatDate } from '@/lib/format'; import { Plus, Clock, Bell, Keyboard, Sun, Moon } from 'lucide-react'; +import { FeedbackButton } from './FeedbackButton'; import { useTheme } from '@/lib/use-theme'; export function Dashboard() { @@ -72,8 +73,21 @@ export function Dashboard() { .slice(-10) .reverse(); - // Tab title update + // Tab title update with flash on fire + const hasFiring = timers.some((t) => t.state === 'firing'); useEffect(() => { + if (hasFiring) { + // Flash between alarm text and timer label + const firingTimer = timers.find((t) => t.state === 'firing'); + const label = firingTimer?.label ?? 'Timer'; + let flash = true; + const interval = setInterval(() => { + document.title = flash ? `🔔 TIME! — ${label}` : `⏰ ${label} | ChronoMind`; + flash = !flash; + }, 800); + return () => clearInterval(interval); + } + const next = activeTimers .filter((t) => ['active', 'warning'].includes(t.state)) .sort((a, b) => a.targetTime - b.targetTime)[0]; @@ -86,7 +100,7 @@ export function Dashboard() { } else { document.title = 'ChronoMind — Smart Pre-Warning Timer'; } - }, [now, activeTimers]); + }, [now, activeTimers, hasFiring, timers]); return (
@@ -226,6 +240,9 @@ export function Dashboard() { {/* Create Timer Modal */} setIsCreateOpen(false)} /> + {/* Feedback button */} + + {/* Footer */}