diff --git a/web/src/components/CreateTimerModal.tsx b/web/src/components/CreateTimerModal.tsx index 6833bea..c8265e0 100644 --- a/web/src/components/CreateTimerModal.tsx +++ b/web/src/components/CreateTimerModal.tsx @@ -10,6 +10,7 @@ import { X, AlarmClock, Timer, Coffee, Sparkles, CalendarDays } from 'lucide-rea import { BUILT_IN_CATEGORIES, getCategoryById } from '@/lib/categories'; import { parseNaturalLanguage } from '@/lib/nl-parser'; import type { ParseResult } from '@/lib/nl-parser'; +import { alarmSchema, countdownSchema, pomodoroSchema, eventSchema } from '@/lib/schemas'; type TabType = 'alarm' | 'countdown' | 'pomodoro' | 'event'; @@ -46,6 +47,9 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { // Event fields const [eventDate, setEventDate] = useState(''); + // Validation errors + const [errors, setErrors] = useState>({}); + if (!isOpen) return null; const handleNlChange = (value: string) => { @@ -100,11 +104,18 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { }; const handleCreate = () => { + setErrors({}); const cascade = { preset: cascadePreset, intervals: [] as number[] }; const catOrUndef = category || undefined; if (tab === 'alarm') { - if (!alarmTime) return; + const result = alarmSchema.safeParse({ label: label || 'Alarm', alarmTime, urgency, cascadePreset }); + if (!result.success) { + const fieldErrors: Record = {}; + for (const issue of result.error.issues) { fieldErrors[issue.path[0] as string] = issue.message; } + setErrors(fieldErrors); + return; + } const [h, m] = alarmTime.split(':').map(Number); const target = new Date(); target.setHours(h, m, 0, 0); @@ -112,25 +123,38 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { target.setDate(target.getDate() + 1); } addAlarm({ - label: label || 'Alarm', + label: result.data.label, targetTime: target.getTime(), urgency, cascade, category: catOrUndef, }); } else if (tab === 'countdown') { + const result = countdownSchema.safeParse({ label: label || 'Countdown', hours, minutes, seconds, urgency, cascadePreset }); + if (!result.success) { + const fieldErrors: Record = {}; + for (const issue of result.error.issues) { fieldErrors[issue.path[0] as string] = issue.message; } + setErrors(fieldErrors); + return; + } const durationMs = (hours * 3600 + minutes * 60 + seconds) * 1000; - if (durationMs <= 0) return; addCountdown({ - label: label || 'Countdown', + label: result.data.label, durationMs, urgency, cascade, category: catOrUndef, }); } else if (tab === 'pomodoro') { + const result = pomodoroSchema.safeParse({ label: label || 'Focus Session', workMinutes: workMin, breakMinutes: breakMin, longBreakMinutes: longBreakMin, rounds, urgency }); + if (!result.success) { + const fieldErrors: Record = {}; + for (const issue of result.error.issues) { fieldErrors[issue.path[0] as string] = issue.message; } + setErrors(fieldErrors); + return; + } addPomodoro({ - label: label || 'Focus Session', + label: result.data.label, config: { workMinutes: workMin, breakMinutes: breakMin, @@ -140,11 +164,16 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { urgency, }); } else if (tab === 'event') { - if (!eventDate) return; + const result = eventSchema.safeParse({ label: label || 'Event Countdown', eventDate, urgency, cascadePreset }); + if (!result.success) { + const fieldErrors: Record = {}; + for (const issue of result.error.issues) { fieldErrors[issue.path[0] as string] = issue.message; } + setErrors(fieldErrors); + return; + } const target = new Date(eventDate).getTime(); - if (target <= Date.now()) return; addEvent({ - label: label || 'Event Countdown', + label: result.data.label, targetTime: target, urgency, category: catOrUndef, @@ -159,6 +188,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { setSeconds(0); setCategory(''); setEventDate(''); + setErrors({}); onClose(); }; @@ -276,10 +306,11 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2" style={{ backgroundColor: 'var(--cm-surface-card)', - borderColor: 'var(--cm-border)', + borderColor: errors.label ? 'var(--cm-danger)' : 'var(--cm-border)', color: 'var(--cm-text-primary)', }} /> + {errors.label &&

{errors.label}

} {/* Tab-specific fields */} @@ -295,10 +326,11 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2" style={{ backgroundColor: 'var(--cm-surface-card)', - borderColor: 'var(--cm-border)', + borderColor: errors.alarmTime ? 'var(--cm-danger)' : 'var(--cm-border)', color: 'var(--cm-text-primary)', }} /> + {errors.alarmTime &&

{errors.alarmTime}

} )} @@ -333,6 +365,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { ))} + {errors.minutes &&

{errors.minutes}

} {/* Quick presets */}
{[ @@ -405,6 +438,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { color: 'var(--cm-text-primary)', }} /> + {errors.eventDate &&

{errors.eventDate}

} {eventDate && new Date(eventDate).getTime() > Date.now() && (

{Math.ceil((new Date(eventDate).getTime() - Date.now()) / 86_400_000)} days from now · Milestone warnings at 30, 7, 3, 1 days diff --git a/web/src/lib/schemas.ts b/web/src/lib/schemas.ts index b9b44bc..6a7b176 100644 --- a/web/src/lib/schemas.ts +++ b/web/src/lib/schemas.ts @@ -29,6 +29,17 @@ export const pomodoroSchema = z.object({ urgency: z.enum(['critical', 'important', 'standard', 'gentle', 'passive']), }); +export const eventSchema = z.object({ + label: z.string().max(100, 'Label too long').default('Event Countdown'), + eventDate: z.string().min(1, 'Event date is required'), + urgency: z.enum(['critical', 'important', 'standard', 'gentle', 'passive']), + cascadePreset: z.enum(['aggressive', 'standard', 'light', 'minimal', 'none', 'custom']), +}).refine( + (data) => new Date(data.eventDate).getTime() > Date.now(), + { message: 'Event date must be in the future', path: ['eventDate'] } +); + export type AlarmFormData = z.infer; export type CountdownFormData = z.infer; export type PomodoroFormData = z.infer; +export type EventFormData = z.infer;