feat: add Zod schemas, settings page with sound preview, notification controls
This commit is contained in:
parent
d2b5563414
commit
cad95be62a
65
ios/ChronoMind/Shared/TimerEngine/Format.swift
Normal file
65
ios/ChronoMind/Shared/TimerEngine/Format.swift
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// ── Format Utilities ───────────────────────────────────────────
|
||||||
|
// Ported from web/src/lib/format.ts
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Duration Formatting
|
||||||
|
|
||||||
|
/// Format seconds as HH:MM:SS or MM:SS
|
||||||
|
func formatDuration(_ seconds: TimeInterval) -> String {
|
||||||
|
guard seconds > 0 else { return "00:00" }
|
||||||
|
let totalSeconds = Int(seconds)
|
||||||
|
let hours = totalSeconds / 3600
|
||||||
|
let minutes = (totalSeconds % 3600) / 60
|
||||||
|
let secs = totalSeconds % 60
|
||||||
|
|
||||||
|
if hours > 0 {
|
||||||
|
return String(format: "%02d:%02d:%02d", hours, minutes, secs)
|
||||||
|
}
|
||||||
|
return String(format: "%02d:%02d", minutes, secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format seconds as compact string: "2h 15m", "45m", "30s"
|
||||||
|
func formatDurationCompact(_ seconds: TimeInterval) -> String {
|
||||||
|
guard seconds > 0 else { return "0s" }
|
||||||
|
let totalSeconds = Int(seconds)
|
||||||
|
let hours = totalSeconds / 3600
|
||||||
|
let minutes = (totalSeconds % 3600) / 60
|
||||||
|
let secs = totalSeconds % 60
|
||||||
|
|
||||||
|
if hours > 0 && minutes > 0 { return "\(hours)h \(minutes)m" }
|
||||||
|
if hours > 0 { return "\(hours)h" }
|
||||||
|
if minutes > 0 && secs > 0 && minutes < 5 { return "\(minutes)m \(secs)s" }
|
||||||
|
if minutes > 0 { return "\(minutes)m" }
|
||||||
|
return "\(secs)s"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a date as relative time: "in 5m", "2h ago", "now"
|
||||||
|
func formatRelativeTime(_ targetTime: Date, now: Date = Date()) -> String {
|
||||||
|
let diff = targetTime.timeIntervalSince(now)
|
||||||
|
let absDiff = abs(diff)
|
||||||
|
|
||||||
|
if absDiff < 30 { return "now" }
|
||||||
|
|
||||||
|
let compact = formatDurationCompact(absDiff)
|
||||||
|
return diff > 0 ? "in \(compact)" : "\(compact) ago"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format time as "10:42 AM"
|
||||||
|
func formatTime(_ date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "h:mm a"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format date as "Mon, Feb 27"
|
||||||
|
func formatDate(_ date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "EEE, MMM d"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format full date-time as "Mon, Feb 27 at 10:42 AM"
|
||||||
|
func formatDateTime(_ date: Date) -> String {
|
||||||
|
"\(formatDate(date)) at \(formatTime(date))"
|
||||||
|
}
|
||||||
44
ios/ChronoMind/Shared/TimerEngine/TimeBlindness.swift
Normal file
44
ios/ChronoMind/Shared/TimerEngine/TimeBlindness.swift
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
// ── Time Blindness Aids ────────────────────────────────────────
|
||||||
|
// "This is about as long as [familiar reference]"
|
||||||
|
// Ported from web/src/lib/time-blindness.ts
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Time Reference
|
||||||
|
|
||||||
|
private struct TimeReference {
|
||||||
|
let maxMinutes: Int
|
||||||
|
let label: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private let timeReferences: [TimeReference] = [
|
||||||
|
TimeReference(maxMinutes: 1, label: "a deep breath"),
|
||||||
|
TimeReference(maxMinutes: 2, label: "brushing your teeth"),
|
||||||
|
TimeReference(maxMinutes: 3, label: "making instant coffee"),
|
||||||
|
TimeReference(maxMinutes: 5, label: "a short walk around the block"),
|
||||||
|
TimeReference(maxMinutes: 10, label: "a quick shower"),
|
||||||
|
TimeReference(maxMinutes: 15, label: "a coffee break"),
|
||||||
|
TimeReference(maxMinutes: 20, label: "a short podcast episode"),
|
||||||
|
TimeReference(maxMinutes: 25, label: "one Pomodoro session"),
|
||||||
|
TimeReference(maxMinutes: 30, label: "a TV sitcom episode"),
|
||||||
|
TimeReference(maxMinutes: 45, label: "a yoga class"),
|
||||||
|
TimeReference(maxMinutes: 60, label: "one hour-long meeting"),
|
||||||
|
TimeReference(maxMinutes: 90, label: "a movie"),
|
||||||
|
TimeReference(maxMinutes: 120, label: "a long movie or flight"),
|
||||||
|
TimeReference(maxMinutes: 180, label: "a half-day workshop"),
|
||||||
|
TimeReference(maxMinutes: 240, label: "a road trip playlist"),
|
||||||
|
TimeReference(maxMinutes: 480, label: "a full work day"),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Get a familiar time reference for a given duration in minutes.
|
||||||
|
/// e.g., "About as long as a TV sitcom episode"
|
||||||
|
func getTimeReference(minutes: Int) -> String? {
|
||||||
|
guard minutes > 0 else { return nil }
|
||||||
|
guard let ref = timeReferences.first(where: { minutes <= $0.maxMinutes }) else { return nil }
|
||||||
|
return "About as long as \(ref.label)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get time reference for seconds.
|
||||||
|
func getTimeReference(seconds: TimeInterval) -> String? {
|
||||||
|
getTimeReference(minutes: Int((seconds / 60).rounded()))
|
||||||
|
}
|
||||||
405
ios/ChronoMind/Shared/TimerEngine/TimerEngine.swift
Normal file
405
ios/ChronoMind/Shared/TimerEngine/TimerEngine.swift
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
// ── 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
|
||||||
|
}
|
||||||
216
web/src/app/settings/page.tsx
Normal file
216
web/src/app/settings/page.tsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft, Volume2, Bell, Palette, Trash2 } from 'lucide-react';
|
||||||
|
import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency';
|
||||||
|
import { previewSound } from '@/lib/sounds';
|
||||||
|
import { getNotificationPermission, requestNotificationPermission } from '@/lib/notifications';
|
||||||
|
import { useTimerStore } from '@/lib/store';
|
||||||
|
import { useTheme } from '@/lib/use-theme';
|
||||||
|
import type { NotificationPermission as NotifPerm } from '@/lib/notifications';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [notifPerm, setNotifPerm] = useState<NotifPerm>('default');
|
||||||
|
const { theme, toggle: toggleTheme } = useTheme();
|
||||||
|
const timers = useTimerStore((s) => s.timers);
|
||||||
|
const removeTimer = useTimerStore((s) => s.removeTimer);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
setNotifPerm(getNotificationPermission());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
const completedCount = timers.filter((t) => ['dismissed', 'completed'].includes(t.state)).length;
|
||||||
|
|
||||||
|
const clearHistory = () => {
|
||||||
|
timers
|
||||||
|
.filter((t) => ['dismissed', 'completed'].includes(t.state))
|
||||||
|
.forEach((t) => removeTimer(t.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-sm mb-8"
|
||||||
|
style={{ color: 'var(--cm-accent)' }}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold mb-8" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Theme */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="flex items-center gap-2 text-base font-semibold mb-4" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
<Palette size={18} /> Appearance
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--cm-text-primary)' }}>Theme</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
Current: {theme === 'dark' ? 'Dark' : 'Light'}. System preference detected automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
|
||||||
|
>
|
||||||
|
Switch to {theme === 'dark' ? 'Light' : 'Dark'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="flex items-center gap-2 text-base font-semibold mb-4" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
<Bell size={18} /> Notifications
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
Browser Notifications
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
Status: {notifPerm === 'granted' ? 'Enabled' : notifPerm === 'denied' ? 'Blocked' : 'Not requested'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{notifPerm !== 'granted' && notifPerm !== 'denied' && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const result = await requestNotificationPermission();
|
||||||
|
setNotifPerm(result);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{notifPerm === 'denied' && (
|
||||||
|
<p className="text-xs mt-2" style={{ color: 'var(--cm-danger)' }}>
|
||||||
|
Notifications are blocked. Please enable them in your browser settings.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Sound Preview */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="flex items-center gap-2 text-base font-semibold mb-4" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
<Volume2 size={18} /> Sound Preview
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4 space-y-3"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
Each urgency level has a distinct alarm sound. Click to preview.
|
||||||
|
</p>
|
||||||
|
{URGENCY_ORDER.map((level) => {
|
||||||
|
const config = getUrgencyConfig(level);
|
||||||
|
return (
|
||||||
|
<div key={level} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: config.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
{config.soundEnabled ? `${config.notificationStyle}` : 'silent'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => previewSound(level)}
|
||||||
|
className="px-3 py-1 rounded-lg text-xs font-medium cursor-pointer"
|
||||||
|
style={{ backgroundColor: config.bgColor, color: config.color }}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Data */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="flex items-center gap-2 text-base font-semibold mb-4" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
<Trash2 size={18} /> Data
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
Clear Completed Timers
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
{completedCount} completed/dismissed timers in history
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={clearHistory}
|
||||||
|
disabled={completedCount === 0}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-30"
|
||||||
|
style={{ backgroundColor: 'rgba(255,71,87,0.15)', color: 'var(--cm-danger)' }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-3" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
All data is stored locally in your browser. No data is sent to any server.
|
||||||
|
See our <Link href="/privacy" style={{ color: 'var(--cm-accent)' }}>Privacy Policy</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* About */}
|
||||||
|
<section>
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4 text-center"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
ChronoMind v0.1.0
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
Smart Pre-Warning Timer ·{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/saravanakumardb1/learning_ai_clock"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--cm-accent)' }}
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
web/src/lib/schemas.ts
Normal file
34
web/src/lib/schemas.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// ── Zod Validation Schemas for Timer Creation ──────────────────
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const alarmSchema = z.object({
|
||||||
|
label: z.string().min(1, 'Label is required').max(100, 'Label too long'),
|
||||||
|
alarmTime: z.string().regex(/^\d{2}:\d{2}$/, 'Invalid time format (HH:MM)'),
|
||||||
|
urgency: z.enum(['critical', 'important', 'standard', 'gentle', 'passive']),
|
||||||
|
cascadePreset: z.enum(['aggressive', 'standard', 'light', 'minimal', 'none', 'custom']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const countdownSchema = z.object({
|
||||||
|
label: z.string().min(1, 'Label is required').max(100, 'Label too long'),
|
||||||
|
hours: z.number().int().min(0).max(23),
|
||||||
|
minutes: z.number().int().min(0).max(59),
|
||||||
|
seconds: z.number().int().min(0).max(59),
|
||||||
|
urgency: z.enum(['critical', 'important', 'standard', 'gentle', 'passive']),
|
||||||
|
cascadePreset: z.enum(['aggressive', 'standard', 'light', 'minimal', 'none', 'custom']),
|
||||||
|
}).refine(
|
||||||
|
(data) => data.hours > 0 || data.minutes > 0 || data.seconds > 0,
|
||||||
|
{ message: 'Duration must be greater than zero', path: ['minutes'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const pomodoroSchema = z.object({
|
||||||
|
label: z.string().max(100, 'Label too long').default('Focus Session'),
|
||||||
|
workMinutes: z.number().int().min(1, 'Min 1 minute').max(120, 'Max 2 hours'),
|
||||||
|
breakMinutes: z.number().int().min(1, 'Min 1 minute').max(60, 'Max 1 hour'),
|
||||||
|
longBreakMinutes: z.number().int().min(1, 'Min 1 minute').max(60, 'Max 1 hour'),
|
||||||
|
rounds: z.number().int().min(1, 'Min 1 round').max(12, 'Max 12 rounds'),
|
||||||
|
urgency: z.enum(['critical', 'important', 'standard', 'gentle', 'passive']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AlarmFormData = z.infer<typeof alarmSchema>;
|
||||||
|
export type CountdownFormData = z.infer<typeof countdownSchema>;
|
||||||
|
export type PomodoroFormData = z.infer<typeof pomodoroSchema>;
|
||||||
Loading…
Reference in New Issue
Block a user