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