feat(web): wire Zod form validation into CreateTimerModal + add event schema

- Import and validate against alarmSchema, countdownSchema, pomodoroSchema, eventSchema
- Show inline error messages below form fields (label, time, duration, event date)
- Error border color highlights invalid fields
- Errors clear on tab switch and successful creation
This commit is contained in:
saravanakumardb1 2026-02-28 13:30:56 -08:00
parent 3d70a7c197
commit 02ac682c52
2 changed files with 55 additions and 10 deletions

View File

@ -10,6 +10,7 @@ import { X, AlarmClock, Timer, Coffee, Sparkles, CalendarDays } from 'lucide-rea
import { BUILT_IN_CATEGORIES, getCategoryById } from '@/lib/categories'; import { BUILT_IN_CATEGORIES, getCategoryById } from '@/lib/categories';
import { parseNaturalLanguage } from '@/lib/nl-parser'; import { parseNaturalLanguage } from '@/lib/nl-parser';
import type { ParseResult } 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'; type TabType = 'alarm' | 'countdown' | 'pomodoro' | 'event';
@ -46,6 +47,9 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
// Event fields // Event fields
const [eventDate, setEventDate] = useState(''); const [eventDate, setEventDate] = useState('');
// Validation errors
const [errors, setErrors] = useState<Record<string, string>>({});
if (!isOpen) return null; if (!isOpen) return null;
const handleNlChange = (value: string) => { const handleNlChange = (value: string) => {
@ -100,11 +104,18 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
}; };
const handleCreate = () => { const handleCreate = () => {
setErrors({});
const cascade = { preset: cascadePreset, intervals: [] as number[] }; const cascade = { preset: cascadePreset, intervals: [] as number[] };
const catOrUndef = category || undefined; const catOrUndef = category || undefined;
if (tab === 'alarm') { if (tab === 'alarm') {
if (!alarmTime) return; const result = alarmSchema.safeParse({ label: label || 'Alarm', alarmTime, urgency, cascadePreset });
if (!result.success) {
const fieldErrors: Record<string, string> = {};
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 [h, m] = alarmTime.split(':').map(Number);
const target = new Date(); const target = new Date();
target.setHours(h, m, 0, 0); target.setHours(h, m, 0, 0);
@ -112,25 +123,38 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
target.setDate(target.getDate() + 1); target.setDate(target.getDate() + 1);
} }
addAlarm({ addAlarm({
label: label || 'Alarm', label: result.data.label,
targetTime: target.getTime(), targetTime: target.getTime(),
urgency, urgency,
cascade, cascade,
category: catOrUndef, category: catOrUndef,
}); });
} else if (tab === 'countdown') { } else if (tab === 'countdown') {
const result = countdownSchema.safeParse({ label: label || 'Countdown', hours, minutes, seconds, urgency, cascadePreset });
if (!result.success) {
const fieldErrors: Record<string, string> = {};
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; const durationMs = (hours * 3600 + minutes * 60 + seconds) * 1000;
if (durationMs <= 0) return;
addCountdown({ addCountdown({
label: label || 'Countdown', label: result.data.label,
durationMs, durationMs,
urgency, urgency,
cascade, cascade,
category: catOrUndef, category: catOrUndef,
}); });
} else if (tab === 'pomodoro') { } 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<string, string> = {};
for (const issue of result.error.issues) { fieldErrors[issue.path[0] as string] = issue.message; }
setErrors(fieldErrors);
return;
}
addPomodoro({ addPomodoro({
label: label || 'Focus Session', label: result.data.label,
config: { config: {
workMinutes: workMin, workMinutes: workMin,
breakMinutes: breakMin, breakMinutes: breakMin,
@ -140,11 +164,16 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
urgency, urgency,
}); });
} else if (tab === 'event') { } else if (tab === 'event') {
if (!eventDate) return; const result = eventSchema.safeParse({ label: label || 'Event Countdown', eventDate, urgency, cascadePreset });
if (!result.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of result.error.issues) { fieldErrors[issue.path[0] as string] = issue.message; }
setErrors(fieldErrors);
return;
}
const target = new Date(eventDate).getTime(); const target = new Date(eventDate).getTime();
if (target <= Date.now()) return;
addEvent({ addEvent({
label: label || 'Event Countdown', label: result.data.label,
targetTime: target, targetTime: target,
urgency, urgency,
category: catOrUndef, category: catOrUndef,
@ -159,6 +188,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
setSeconds(0); setSeconds(0);
setCategory(''); setCategory('');
setEventDate(''); setEventDate('');
setErrors({});
onClose(); 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" className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2"
style={{ style={{
backgroundColor: 'var(--cm-surface-card)', backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)', borderColor: errors.label ? 'var(--cm-danger)' : 'var(--cm-border)',
color: 'var(--cm-text-primary)', color: 'var(--cm-text-primary)',
}} }}
/> />
{errors.label && <p className="text-xs mt-1" style={{ color: 'var(--cm-danger)' }}>{errors.label}</p>}
</div> </div>
{/* Tab-specific fields */} {/* 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" className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2"
style={{ style={{
backgroundColor: 'var(--cm-surface-card)', backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)', borderColor: errors.alarmTime ? 'var(--cm-danger)' : 'var(--cm-border)',
color: 'var(--cm-text-primary)', color: 'var(--cm-text-primary)',
}} }}
/> />
{errors.alarmTime && <p className="text-xs mt-1" style={{ color: 'var(--cm-danger)' }}>{errors.alarmTime}</p>}
</div> </div>
)} )}
@ -333,6 +365,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
</div> </div>
))} ))}
</div> </div>
{errors.minutes && <p className="text-xs mt-1" style={{ color: 'var(--cm-danger)' }}>{errors.minutes}</p>}
{/* Quick presets */} {/* Quick presets */}
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
{[ {[
@ -405,6 +438,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
color: 'var(--cm-text-primary)', color: 'var(--cm-text-primary)',
}} }}
/> />
{errors.eventDate && <p className="text-xs mt-1" style={{ color: 'var(--cm-danger)' }}>{errors.eventDate}</p>}
{eventDate && new Date(eventDate).getTime() > Date.now() && ( {eventDate && new Date(eventDate).getTime() > Date.now() && (
<p className="text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}> <p className="text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
{Math.ceil((new Date(eventDate).getTime() - Date.now()) / 86_400_000)} days from now &middot; Milestone warnings at 30, 7, 3, 1 days {Math.ceil((new Date(eventDate).getTime() - Date.now()) / 86_400_000)} days from now &middot; Milestone warnings at 30, 7, 3, 1 days

View File

@ -29,6 +29,17 @@ export const pomodoroSchema = z.object({
urgency: z.enum(['critical', 'important', 'standard', 'gentle', 'passive']), 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<typeof alarmSchema>; export type AlarmFormData = z.infer<typeof alarmSchema>;
export type CountdownFormData = z.infer<typeof countdownSchema>; export type CountdownFormData = z.infer<typeof countdownSchema>;
export type PomodoroFormData = z.infer<typeof pomodoroSchema>; export type PomodoroFormData = z.infer<typeof pomodoroSchema>;
export type EventFormData = z.infer<typeof eventSchema>;