Skip to content

Commit 7441e3e

Browse files
authored
feat: simplify the date picking usage when a user schedules an email and remove premature "in the past" warning (#1983)
1 parent 8d1a965 commit 7441e3e

File tree

2 files changed

+260
-75
lines changed

2 files changed

+260
-75
lines changed
Lines changed: 163 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
2-
import { Clock } from 'lucide-react';
3-
import { format, isValid } from 'date-fns';
4-
import { useState, useEffect } from 'react';
2+
import { Clock, Calendar as CalendarIcon } from 'lucide-react';
3+
import { format, startOfToday } from 'date-fns';
4+
import { useState, useCallback } from 'react';
55
import { cn } from '@/lib/utils';
66
import { toast } from 'sonner';
7+
import { Calendar } from '@/components/ui/calendar';
8+
import { Input } from '@/components/ui/input';
9+
10+
const pad2 = (n: number) => n.toString().padStart(2, '0');
11+
const getLocalTimeFromDate = (d: Date) => `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
12+
const getNowTime = () => getLocalTimeFromDate(new Date());
713

814
interface ScheduleSendPickerProps {
915
value?: string | undefined;
@@ -12,107 +18,191 @@ interface ScheduleSendPickerProps {
1218
onValidityChange?: (isValid: boolean) => void;
1319
}
1420

15-
const toLocalInputValue = (date: Date) => {
16-
const tzOffsetMs = date.getTimezoneOffset() * 60 * 1000;
17-
const local = new Date(date.getTime() - tzOffsetMs);
18-
return local.toISOString().slice(0, 16);
19-
};
20-
2121
export const ScheduleSendPicker: React.FC<ScheduleSendPickerProps> = ({
2222
value,
2323
onChange,
2424
className,
2525
onValidityChange,
2626
}) => {
27-
const [isOpen, setIsOpen] = useState(false);
27+
const [datePickerOpen, setDatePickerOpen] = useState(false);
28+
const [timePickerOpen, setTimePickerOpen] = useState(false);
2829

29-
const [localValue, setLocalValue] = useState<string>(() => {
30-
if (value) {
31-
const d = new Date(value);
32-
if (!isNaN(d.getTime())) return toLocalInputValue(d);
33-
}
34-
return '';
35-
});
36-
37-
useEffect(() => {
38-
if (value) {
39-
const d = new Date(value);
40-
if (!isNaN(d.getTime())) {
41-
setLocalValue(toLocalInputValue(d));
42-
}
43-
} else {
44-
setLocalValue('');
45-
}
46-
}, [value]);
47-
48-
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
49-
const val = e.target.value;
50-
setLocalValue(val);
30+
const isScheduling = !!value;
31+
const selectedDate = value ? new Date(value) : undefined;
32+
const time = value ? getLocalTimeFromDate(new Date(value)) : getNowTime();
5133

52-
if (!val) {
34+
const emitChange = useCallback((datePart: Date | undefined, timePart: string, validate: boolean = false) => {
35+
if (!datePart) {
5336
onChange(undefined);
54-
onValidityChange?.(true);
37+
if (validate) {
38+
onValidityChange?.(true);
39+
}
5540
return;
5641
}
5742

58-
const maybeDate = new Date(val);
43+
const [hhStr, mmStr = '00'] = timePart.split(':');
44+
const hours = Number(hhStr);
45+
const minutes = Number(mmStr);
5946

60-
// Invalid date string
61-
if (isNaN(maybeDate.getTime())) {
62-
onValidityChange?.(false);
47+
if (Number.isNaN(hours) || Number.isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
48+
if (validate) {
49+
onValidityChange?.(false);
50+
}
6351
return;
6452
}
6553

66-
const now = new Date();
67-
if (maybeDate.getTime() < now.getTime()) {
54+
const combinedDate = new Date(datePart);
55+
combinedDate.setHours(hours, minutes, 0, 0);
56+
57+
if (validate && combinedDate.getTime() < Date.now()) {
6858
toast.error('Scheduled time cannot be in the past');
6959
onValidityChange?.(false);
7060
return;
7161
}
7262

73-
onValidityChange?.(true);
74-
onChange(maybeDate.toISOString());
63+
if (validate) {
64+
onValidityChange?.(true);
65+
}
66+
onChange(combinedDate.toISOString());
67+
}, [onChange, onValidityChange]);
68+
69+
const handleDateSelect = useCallback((d?: Date) => {
70+
emitChange(d, time, false);
71+
}, [emitChange, time]);
72+
73+
const handleTimeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
74+
const val = e.target.value;
75+
emitChange(selectedDate, val, false);
76+
}, [selectedDate, emitChange]);
77+
78+
const handleDatePickerClose = useCallback((open: boolean) => {
79+
setDatePickerOpen(open);
80+
if (!open && selectedDate) {
81+
emitChange(selectedDate, time, true);
82+
}
83+
}, [selectedDate, time, emitChange]);
84+
85+
const handleTimePickerClose = useCallback((open: boolean) => {
86+
setTimePickerOpen(open);
87+
if (!open && selectedDate) {
88+
emitChange(selectedDate, time, true);
89+
}
90+
}, [selectedDate, time, emitChange]);
91+
92+
const handleToggleScheduling = useCallback(() => {
93+
if (isScheduling) {
94+
onChange(undefined);
95+
} else {
96+
const now = new Date();
97+
emitChange(now, getNowTime());
98+
}
99+
}, [isScheduling, onChange, emitChange]);
100+
101+
const formatTime12Hour = (timeStr: string) => {
102+
try {
103+
const [hhStr, mmStr = '00'] = timeStr.split(':');
104+
const preview = new Date();
105+
preview.setHours(Number(hhStr), Number(mmStr), 0, 0);
106+
return format(preview, 'hh:mm aaa');
107+
} catch {
108+
return timeStr;
109+
}
75110
};
76111

77-
const displayValue = localValue || toLocalInputValue(new Date());
112+
const triggerLabel = (() => {
113+
if (!selectedDate) return 'Send later';
114+
try {
115+
const formattedTime = formatTime12Hour(time);
116+
const formattedDate = format(selectedDate, 'dd MMM yyyy');
117+
return `${formattedDate} ${formattedTime}`;
118+
} catch {
119+
return 'Send later';
120+
}
121+
})();
122+
123+
if (isScheduling) {
124+
return (
125+
<>
126+
<Popover open={datePickerOpen} onOpenChange={handleDatePickerClose}>
127+
<PopoverTrigger asChild>
128+
<button
129+
type="button"
130+
className={cn(
131+
'flex items-center gap-1 rounded-md border px-2 py-1 text-sm hover:bg-accent',
132+
className,
133+
)}
134+
>
135+
<CalendarIcon className="h-4 w-4" />
136+
<span>
137+
{selectedDate ? format(selectedDate, 'dd MMM yyyy') : 'Select Date'}
138+
</span>
139+
</button>
140+
</PopoverTrigger>
141+
<PopoverContent className="z-[100] w-auto p-4" align="start" side="top" sideOffset={8}>
142+
<div className="space-y-4">
143+
<Calendar
144+
mode="single"
145+
selected={selectedDate}
146+
onSelect={handleDateSelect}
147+
disabled={{ before: startOfToday() }}
148+
className="rounded-md"
149+
captionLayout="dropdown"
150+
/>
151+
</div>
152+
</PopoverContent>
153+
</Popover>
154+
155+
<Popover open={timePickerOpen} onOpenChange={handleTimePickerClose}>
156+
<PopoverTrigger asChild>
157+
<button
158+
type="button"
159+
className={cn(
160+
'flex items-center gap-1 rounded-md border px-2 py-1 text-sm hover:bg-accent',
161+
className,
162+
)}
163+
>
164+
<Clock className="h-4 w-4" />
165+
<span>{formatTime12Hour(time)}</span>
166+
</button>
167+
</PopoverTrigger>
168+
<PopoverContent className="z-[100] w-auto p-4" align="start" side="top" sideOffset={8}>
169+
<div className="space-y-4">
170+
<h3 className="text-sm font-medium">Select Time</h3>
171+
<Input
172+
type="time"
173+
value={time}
174+
onChange={handleTimeChange}
175+
className="w-full"
176+
/>
177+
</div>
178+
</PopoverContent>
179+
</Popover>
78180

79-
return (
80-
<Popover open={isOpen} onOpenChange={setIsOpen}>
81-
<PopoverTrigger asChild>
82181
<button
83182
type="button"
183+
onClick={handleToggleScheduling}
84184
className={cn(
85185
'flex items-center gap-1 rounded-md border px-2 py-1 text-sm hover:bg-accent',
86186
className,
87187
)}
88188
>
89-
<Clock className="h-4 w-4" />
90-
<span>
91-
{(() => {
92-
if (!localValue) return 'Send later';
93-
const parsed = new Date(localValue);
94-
if (!isValid(parsed)) return 'Send later';
95-
try {
96-
return format(parsed, 'dd MMM yyyy hh:mm aaa');
97-
} catch (error) {
98-
console.error('Error formatting date', error);
99-
return 'Send later';
100-
}
101-
})()}
102-
</span>
189+
<span>Cancel</span>
103190
</button>
104-
</PopoverTrigger>
105-
<PopoverContent className="z-[100] w-64 p-4" align="start" side="top" sideOffset={8}>
106-
<div className="flex flex-col gap-4">
107-
<label className="text-sm font-semibold">Choose date & time</label>
108-
<input
109-
type="datetime-local"
110-
value={displayValue}
111-
onChange={handleChange}
112-
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:opacity-0"
113-
/>
114-
</div>
115-
</PopoverContent>
116-
</Popover>
191+
</>
192+
);
193+
}
194+
195+
return (
196+
<button
197+
type="button"
198+
onClick={handleToggleScheduling}
199+
className={cn(
200+
'flex items-center gap-1 rounded-md border px-2 py-1 text-sm hover:bg-accent',
201+
className,
202+
)}
203+
>
204+
<Clock className="h-4 w-4" />
205+
<span>{triggerLabel}</span>
206+
</button>
117207
);
118208
};

0 commit comments

Comments
 (0)