diff --git a/ios/ChronoMind/Shared/TimerEngine/Format.swift b/ios/ChronoMind/Shared/TimerEngine/Format.swift new file mode 100644 index 0000000..08004b6 --- /dev/null +++ b/ios/ChronoMind/Shared/TimerEngine/Format.swift @@ -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))" +} diff --git a/ios/ChronoMind/Shared/TimerEngine/TimeBlindness.swift b/ios/ChronoMind/Shared/TimerEngine/TimeBlindness.swift new file mode 100644 index 0000000..3b40ae1 --- /dev/null +++ b/ios/ChronoMind/Shared/TimerEngine/TimeBlindness.swift @@ -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())) +} diff --git a/ios/ChronoMind/Shared/TimerEngine/TimerEngine.swift b/ios/ChronoMind/Shared/TimerEngine/TimerEngine.swift new file mode 100644 index 0000000..e369ac1 --- /dev/null +++ b/ios/ChronoMind/Shared/TimerEngine/TimerEngine.swift @@ -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 +} diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx new file mode 100644 index 0000000..7d55cb7 --- /dev/null +++ b/web/src/app/settings/page.tsx @@ -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('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 ( +
+
+ + Back to Dashboard + + +

+ Settings +

+ + {/* Theme */} +
+

+ Appearance +

+
+
+
+

Theme

+

+ Current: {theme === 'dark' ? 'Dark' : 'Light'}. System preference detected automatically. +

+
+ +
+
+
+ + {/* Notifications */} +
+

+ Notifications +

+
+
+
+

+ Browser Notifications +

+

+ Status: {notifPerm === 'granted' ? 'Enabled' : notifPerm === 'denied' ? 'Blocked' : 'Not requested'} +

+
+ {notifPerm !== 'granted' && notifPerm !== 'denied' && ( + + )} +
+ {notifPerm === 'denied' && ( +

+ Notifications are blocked. Please enable them in your browser settings. +

+ )} +
+
+ + {/* Sound Preview */} +
+

+ Sound Preview +

+
+

+ Each urgency level has a distinct alarm sound. Click to preview. +

+ {URGENCY_ORDER.map((level) => { + const config = getUrgencyConfig(level); + return ( +
+
+
+ + {config.label} + + + {config.soundEnabled ? `${config.notificationStyle}` : 'silent'} + +
+ +
+ ); + })} +
+
+ + {/* Data */} +
+

+ Data +

+
+
+
+

+ Clear Completed Timers +

+

+ {completedCount} completed/dismissed timers in history +

+
+ +
+

+ All data is stored locally in your browser. No data is sent to any server. + See our Privacy Policy. +

+
+
+ + {/* About */} +
+
+

+ ChronoMind v0.1.0 +

+

+ Smart Pre-Warning Timer ·{' '} + + GitHub + +

+
+
+
+
+ ); +} diff --git a/web/src/lib/schemas.ts b/web/src/lib/schemas.ts new file mode 100644 index 0000000..b9b44bc --- /dev/null +++ b/web/src/lib/schemas.ts @@ -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; +export type CountdownFormData = z.infer; +export type PomodoroFormData = z.infer;