learning_ai_clock/web/src/components/CreateTimerModal.tsx
saravanakumardb1 d3b55a2135 feat(web): add custom pre-warning messages per timer
- Add customMessage field to Timer interface + all Create*Params
- Custom message input in CreateTimerModal (optional, non-pomodoro)
- TimerCard shows custom message, falls back to auto-generated prep tips
- 373/373 tests pass, 0 type errors
2026-02-28 13:37:42 -08:00

585 lines
23 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useTimerStore } from '@/lib/store';
import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency';
import type { UrgencyLevel } from '@/lib/urgency';
import { CASCADE_PRESET_LABELS } from '@/lib/cascade';
import type { CascadePreset } from '@/lib/cascade';
import { X, AlarmClock, Timer, Coffee, Sparkles, CalendarDays } from 'lucide-react';
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';
interface CreateTimerModalProps {
isOpen: boolean;
onClose: () => void;
}
export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
const { addAlarm, addCountdown, addPomodoro, addEvent } = useTimerStore();
const [tab, setTab] = useState<TabType>('countdown');
const [nlInput, setNlInput] = useState('');
const [nlResult, setNlResult] = useState<ParseResult | null>(null);
const [label, setLabel] = useState('');
const [urgency, setUrgency] = useState<UrgencyLevel>('standard');
const [cascadePreset, setCascadePreset] = useState<CascadePreset>('standard');
const [category, setCategory] = useState<string>('');
// Alarm fields
const [alarmTime, setAlarmTime] = useState('');
// Countdown fields
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(25);
const [seconds, setSeconds] = useState(0);
// Pomodoro fields
const [workMin, setWorkMin] = useState(25);
const [breakMin, setBreakMin] = useState(5);
const [longBreakMin, setLongBreakMin] = useState(15);
const [rounds, setRounds] = useState(4);
// Event fields
const [eventDate, setEventDate] = useState('');
// Custom pre-warning message
const [customMessage, setCustomMessage] = useState('');
// Validation errors
const [errors, setErrors] = useState<Record<string, string>>({});
if (!isOpen) return null;
const handleNlChange = (value: string) => {
setNlInput(value);
if (value.trim()) {
setNlResult(parseNaturalLanguage(value));
} else {
setNlResult(null);
}
};
const handleNlCreate = () => {
if (!nlResult?.success || !nlResult.timer) return;
const t = nlResult.timer;
if (t.type === 'countdown' && t.durationMs) {
addCountdown({
label: t.label,
durationMs: t.durationMs,
urgency: t.urgency,
cascade: { preset: t.cascade, intervals: [] },
});
} else if (t.type === 'alarm' && t.targetTime) {
addAlarm({
label: t.label,
targetTime: t.targetTime,
urgency: t.urgency,
cascade: { preset: t.cascade, intervals: [] },
});
} else if (t.type === 'pomodoro') {
addPomodoro({
label: t.label,
config: { workMinutes: 25, breakMinutes: 5, longBreakMinutes: 15, rounds: t.pomodoroRounds ?? 4 },
urgency: t.urgency,
});
}
setNlInput('');
setNlResult(null);
setLabel('');
onClose();
};
// When category changes, update urgency + cascade defaults
const handleCategoryChange = (catId: string) => {
setCategory(catId);
if (catId) {
const cat = getCategoryById(catId);
if (cat) {
setUrgency(cat.defaultUrgency);
setCascadePreset(cat.defaultCascade);
}
}
};
const handleCreate = () => {
setErrors({});
const cascade = { preset: cascadePreset, intervals: [] as number[] };
const catOrUndef = category || undefined;
if (tab === 'alarm') {
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);
if (target.getTime() <= Date.now()) {
target.setDate(target.getDate() + 1);
}
addAlarm({
label: result.data.label,
targetTime: target.getTime(),
urgency,
cascade,
category: catOrUndef,
customMessage: customMessage || undefined,
});
} 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;
addCountdown({
label: result.data.label,
durationMs,
urgency,
cascade,
category: catOrUndef,
customMessage: customMessage || undefined,
});
} 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: result.data.label,
config: {
workMinutes: workMin,
breakMinutes: breakMin,
longBreakMinutes: longBreakMin,
rounds,
},
urgency,
});
} else if (tab === 'event') {
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();
addEvent({
label: result.data.label,
targetTime: target,
urgency,
category: catOrUndef,
customMessage: customMessage || undefined,
});
}
// Reset form
setLabel('');
setAlarmTime('');
setHours(0);
setMinutes(25);
setSeconds(0);
setCategory('');
setEventDate('');
setCustomMessage('');
setErrors({});
onClose();
};
const tabs: { key: TabType; label: string; icon: React.ReactNode }[] = [
{ key: 'countdown', label: 'Countdown', icon: <Timer size={16} /> },
{ key: 'alarm', label: 'Alarm', icon: <AlarmClock size={16} /> },
{ key: 'pomodoro', label: 'Pomodoro', icon: <Coffee size={16} /> },
{ key: 'event', label: 'Event', icon: <CalendarDays size={16} /> },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div
className="relative w-full max-w-md mx-4 rounded-2xl border shadow-2xl overflow-hidden"
style={{
backgroundColor: 'var(--cm-bg-elevated)',
borderColor: 'var(--cm-border)',
}}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b" style={{ borderColor: 'var(--cm-border)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--cm-text-primary)' }}>
New Timer
</h2>
<button
onClick={onClose}
className="p-1 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
>
<X size={20} />
</button>
</div>
{/* Natural Language Input */}
<div className="p-4 border-b" style={{ borderColor: 'var(--cm-border)' }}>
<div className="flex items-center gap-2 mb-1">
<Sparkles size={14} style={{ color: 'var(--cm-accent-secondary)' }} />
<label className="text-xs font-medium" style={{ color: 'var(--cm-text-tertiary)' }}>
Quick create type naturally
</label>
</div>
<div className="flex gap-2">
<input
type="text"
value={nlInput}
onChange={(e) => handleNlChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && nlResult?.success) handleNlCreate(); }}
placeholder='e.g. "meeting in 30 min" or "alarm at 3pm"'
className="flex-1 px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: nlResult?.success ? 'var(--cm-accent-secondary)' : 'var(--cm-border)',
color: 'var(--cm-text-primary)',
}}
/>
{nlResult?.success && (
<button
onClick={handleNlCreate}
className="px-4 py-2 rounded-lg text-xs font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent-secondary)', color: '#000' }}
>
Create
</button>
)}
</div>
{nlResult && nlInput.trim() && (
<div className="mt-2 text-xs" style={{ color: nlResult.success ? 'var(--cm-accent-secondary)' : 'var(--cm-text-tertiary)' }}>
{nlResult.success && nlResult.timer ? (
<span>
{nlResult.timer.type === 'pomodoro' ? 'Pomodoro' : nlResult.timer.type === 'alarm' ? 'Alarm' : 'Countdown'}
{' — '}{nlResult.timer.label}
{nlResult.timer.durationMs ? ` (${Math.round(nlResult.timer.durationMs / 60000)}m)` : ''}
{nlResult.timer.urgency !== 'standard' ? ` [${nlResult.timer.urgency}]` : ''}
</span>
) : (
<span style={{ color: 'var(--cm-text-tertiary)' }}>{nlResult.error}</span>
)}
</div>
)}
</div>
{/* Tabs */}
<div className="flex border-b" style={{ borderColor: 'var(--cm-border)' }}>
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors cursor-pointer"
style={{
color: tab === t.key ? 'var(--cm-accent)' : 'var(--cm-text-tertiary)',
borderBottom: tab === t.key ? '2px solid var(--cm-accent)' : '2px solid transparent',
}}
>
{t.icon} {t.label}
</button>
))}
</div>
{/* Form */}
<div className="p-4 space-y-4">
{/* Label */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Label
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={tab === 'pomodoro' ? 'Focus Session' : tab === 'alarm' ? 'Wake up' : 'Timer'}
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: 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 */}
{tab === 'alarm' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Time
</label>
<input
type="time"
value={alarmTime}
onChange={(e) => setAlarmTime(e.target.value)}
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: 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>
)}
{tab === 'countdown' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Duration
</label>
<div className="flex gap-2">
{[
{ label: 'H', value: hours, setter: setHours, max: 23 },
{ label: 'M', value: minutes, setter: setMinutes, max: 59 },
{ label: 'S', value: seconds, setter: setSeconds, max: 59 },
].map((field) => (
<div key={field.label} className="flex-1">
<input
type="number"
min={0}
max={field.max}
value={field.value}
onChange={(e) => field.setter(Math.min(field.max, Math.max(0, parseInt(e.target.value) || 0)))}
className="w-full px-3 py-2 rounded-lg border text-sm text-center focus:outline-none focus:ring-2"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
color: 'var(--cm-text-primary)',
}}
/>
<span className="block text-center text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
{field.label}
</span>
</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">
{[
{ label: '5m', h: 0, m: 5, s: 0 },
{ label: '15m', h: 0, m: 15, s: 0 },
{ label: '25m', h: 0, m: 25, s: 0 },
{ label: '45m', h: 0, m: 45, s: 0 },
{ label: '1h', h: 1, m: 0, s: 0 },
].map((preset) => (
<button
key={preset.label}
onClick={() => { setHours(preset.h); setMinutes(preset.m); setSeconds(preset.s); }}
className="flex-1 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer"
style={{
backgroundColor: 'var(--cm-surface-muted)',
color: 'var(--cm-text-secondary)',
}}
>
{preset.label}
</button>
))}
</div>
</div>
)}
{tab === 'pomodoro' && (
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'Work (min)', value: workMin, setter: setWorkMin },
{ label: 'Break (min)', value: breakMin, setter: setBreakMin },
{ label: 'Long Break (min)', value: longBreakMin, setter: setLongBreakMin },
{ label: 'Rounds', value: rounds, setter: setRounds },
].map((field) => (
<div key={field.label}>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--cm-text-tertiary)' }}>
{field.label}
</label>
<input
type="number"
min={1}
max={120}
value={field.value}
onChange={(e) => field.setter(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full px-3 py-2 rounded-lg border text-sm text-center focus:outline-none focus:ring-2"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
color: 'var(--cm-text-primary)',
}}
/>
</div>
))}
</div>
)}
{tab === 'event' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Event Date
</label>
<input
type="date"
value={eventDate}
onChange={(e) => setEventDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
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)',
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 &middot; Milestone warnings at 30, 7, 3, 1 days
</p>
)}
</div>
)}
{/* Category */}
{tab !== 'pomodoro' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Category
</label>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => handleCategoryChange('')}
className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer"
style={{
backgroundColor: !category ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
color: !category ? '#fff' : 'var(--cm-text-tertiary)',
border: !category ? '1px solid var(--cm-accent)' : '1px solid transparent',
}}
>
None
</button>
{BUILT_IN_CATEGORIES.map((cat) => (
<button
key={cat.id}
onClick={() => handleCategoryChange(cat.id)}
className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer"
style={{
backgroundColor: category === cat.id ? `${cat.color}20` : 'var(--cm-surface-muted)',
color: category === cat.id ? cat.color : 'var(--cm-text-tertiary)',
border: category === cat.id ? `1px solid ${cat.color}60` : '1px solid transparent',
}}
>
{cat.label}
</button>
))}
</div>
</div>
)}
{/* Urgency (non-pomodoro) */}
{tab !== 'pomodoro' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Urgency
</label>
<div className="flex gap-1">
{URGENCY_ORDER.map((level) => {
const config = getUrgencyConfig(level);
return (
<button
key={level}
onClick={() => setUrgency(level)}
className="flex-1 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer"
style={{
backgroundColor: urgency === level ? config.bgColor : 'var(--cm-surface-muted)',
color: urgency === level ? config.color : 'var(--cm-text-tertiary)',
border: urgency === level ? `1px solid ${config.borderColor}` : '1px solid transparent',
}}
>
{config.label}
</button>
);
})}
</div>
</div>
)}
{/* Custom pre-warning message (non-pomodoro) */}
{tab !== 'pomodoro' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Custom Warning Message <span className="text-xs font-normal" style={{ color: 'var(--cm-text-tertiary)' }}>(optional)</span>
</label>
<input
type="text"
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
placeholder='e.g. "Review your agenda" or "Bring your insurance card"'
maxLength={200}
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)',
color: 'var(--cm-text-primary)',
}}
/>
<p className="text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
Shown in pre-warning notifications instead of auto-generated tips
</p>
</div>
)}
{/* Cascade preset (non-pomodoro) */}
{tab !== 'pomodoro' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Pre-warning Cascade
</label>
<select
value={cascadePreset}
onChange={(e) => setCascadePreset(e.target.value as CascadePreset)}
className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 cursor-pointer"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
color: 'var(--cm-text-primary)',
}}
>
{Object.entries(CASCADE_PRESET_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
)}
{/* Create button */}
<button
onClick={handleCreate}
className="w-full py-3 rounded-xl text-sm font-semibold transition-colors cursor-pointer"
style={{
backgroundColor: 'var(--cm-accent)',
color: '#fff',
}}
>
Create {tab === 'pomodoro' ? 'Pomodoro' : tab === 'alarm' ? 'Alarm' : 'Countdown'}
</button>
</div>
</div>
</div>
);
}