feat: add Zod schemas, settings page with sound preview, notification controls

This commit is contained in:
saravanakumardb1 2026-02-27 21:09:33 -08:00
parent d2b5563414
commit cad95be62a
5 changed files with 764 additions and 0 deletions

View 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))"
}

View 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()))
}

View 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
}

View 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 &middot;{' '}
<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
View 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>;