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 { 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 · Milestone warnings at 30, 7, 3, 1 days
|
{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']),
|
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>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user