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:
parent
3d70a7c197
commit
02ac682c52
@ -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<Record<string, string>>({});
|
||||
|
||||
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<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 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<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;
|
||||
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<string, string> = {};
|
||||
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<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();
|
||||
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 && <p className="text-xs mt-1" style={{ color: 'var(--cm-danger)' }}>{errors.label}</p>}
|
||||
</div>
|
||||
|
||||
{/* 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 && <p className="text-xs mt-1" style={{ color: 'var(--cm-danger)' }}>{errors.alarmTime}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -333,6 +365,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{errors.minutes && <p className="text-xs mt-1" style={{ color: 'var(--cm-danger)' }}>{errors.minutes}</p>}
|
||||
{/* Quick presets */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
{[
|
||||
@ -405,6 +438,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||
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() && (
|
||||
<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 · Milestone warnings at 30, 7, 3, 1 days
|
||||
|
||||
@ -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<typeof alarmSchema>;
|
||||
export type CountdownFormData = z.infer<typeof countdownSchema>;
|
||||
export type PomodoroFormData = z.infer<typeof pomodoroSchema>;
|
||||
export type EventFormData = z.infer<typeof eventSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user