diff --git a/frontend/src/components/todos/TodoForm.tsx b/frontend/src/components/todos/TodoForm.tsx
index 29913ef..0e35ff7 100644
--- a/frontend/src/components/todos/TodoForm.tsx
+++ b/frontend/src/components/todos/TodoForm.tsx
@@ -12,6 +12,7 @@ import {
SheetClose,
} from '@/components/ui/sheet';
import { Input } from '@/components/ui/input';
+import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
@@ -129,11 +130,10 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
- setFormData({ ...formData, due_date: e.target.value })}
+ onChange={(v) => setFormData({ ...formData, due_date: v })}
/>
diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx
new file mode 100644
index 0000000..b365216
--- /dev/null
+++ b/frontend/src/components/ui/date-picker.tsx
@@ -0,0 +1,475 @@
+import * as React from 'react';
+import { createPortal } from 'react-dom';
+import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+// ── Helpers ──
+
+function getDaysInMonth(year: number, month: number): number {
+ return new Date(year, month + 1, 0).getDate();
+}
+
+function getFirstDayOfWeek(year: number, month: number): number {
+ return new Date(year, month, 1).getDay();
+}
+
+function pad(n: number): string {
+ return n.toString().padStart(2, '0');
+}
+
+function formatDate(y: number, m: number, d: number): string {
+ return `${y}-${pad(m + 1)}-${pad(d)}`;
+}
+
+const MONTH_NAMES = [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December',
+];
+
+const DAY_HEADERS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
+
+// ── Props ──
+
+export interface DatePickerProps {
+ mode?: 'date' | 'datetime';
+ value: string;
+ onChange: (value: string) => void;
+ onBlur?: () => void;
+ onKeyDown?: (e: React.KeyboardEvent) => void;
+ id?: string;
+ name?: string;
+ autoComplete?: string;
+ required?: boolean;
+ disabled?: boolean;
+ className?: string;
+ placeholder?: string;
+ min?: string;
+ max?: string;
+ yearRange?: [number, number];
+}
+
+// ── Component ──
+
+const DatePicker = React.forwardRef
(
+ (
+ {
+ mode = 'date',
+ value,
+ onChange,
+ onBlur,
+ onKeyDown,
+ id,
+ name,
+ autoComplete,
+ required,
+ disabled,
+ className,
+ placeholder,
+ min,
+ max,
+ yearRange,
+ },
+ ref
+ ) => {
+ const currentYear = new Date().getFullYear();
+ const [startYear, endYear] = yearRange ?? [1900, currentYear + 20];
+
+ // Parse current value
+ const parseDateValue = () => {
+ if (!value) return null;
+ const parts = value.split('T');
+ const dateParts = parts[0]?.split('-');
+ if (!dateParts || dateParts.length !== 3) return null;
+ const y = parseInt(dateParts[0], 10);
+ const m = parseInt(dateParts[1], 10) - 1;
+ const d = parseInt(dateParts[2], 10);
+ if (isNaN(y) || isNaN(m) || isNaN(d)) return null;
+ const timeParts = parts[1]?.split(':');
+ const hour = timeParts ? parseInt(timeParts[0], 10) : 0;
+ const minute = timeParts ? parseInt(timeParts[1], 10) : 0;
+ return { year: y, month: m, day: d, hour, minute };
+ };
+
+ const parsed = parseDateValue();
+ const today = new Date();
+
+ const [open, setOpen] = React.useState(false);
+ const [viewYear, setViewYear] = React.useState(parsed?.year ?? today.getFullYear());
+ const [viewMonth, setViewMonth] = React.useState(parsed?.month ?? today.getMonth());
+ const [hour, setHour] = React.useState(parsed?.hour ?? 0);
+ const [minute, setMinute] = React.useState(parsed?.minute ?? 0);
+
+ const triggerRef = React.useRef(null);
+ const popupRef = React.useRef(null);
+ const [pos, setPos] = React.useState<{ top: number; left: number; flipped: boolean }>({
+ top: 0,
+ left: 0,
+ flipped: false,
+ });
+
+ // Merge forwarded ref with internal ref
+ React.useImperativeHandle(ref, () => triggerRef.current!);
+
+ // Sync internal state when value changes externally
+ React.useEffect(() => {
+ const p = parseDateValue();
+ if (p) {
+ setViewYear(p.year);
+ setViewMonth(p.month);
+ setHour(p.hour);
+ setMinute(p.minute);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [value]);
+
+ // Position popup
+ const updatePosition = React.useCallback(() => {
+ if (!triggerRef.current) return;
+ const rect = triggerRef.current.getBoundingClientRect();
+ const popupHeight = mode === 'datetime' ? 370 : 320;
+ const spaceBelow = window.innerHeight - rect.bottom;
+ const flipped = spaceBelow < popupHeight && rect.top > popupHeight;
+
+ setPos({
+ top: flipped ? rect.top - popupHeight - 4 : rect.bottom + 4,
+ left: Math.min(rect.left, window.innerWidth - 290),
+ flipped,
+ });
+ }, [mode]);
+
+ React.useEffect(() => {
+ if (!open) return;
+ updatePosition();
+ window.addEventListener('scroll', updatePosition, true);
+ window.addEventListener('resize', updatePosition);
+ return () => {
+ window.removeEventListener('scroll', updatePosition, true);
+ window.removeEventListener('resize', updatePosition);
+ };
+ }, [open, updatePosition]);
+
+ // Dismiss on click outside
+ React.useEffect(() => {
+ if (!open) return;
+ const handler = (e: MouseEvent) => {
+ if (
+ popupRef.current?.contains(e.target as Node) ||
+ triggerRef.current?.contains(e.target as Node)
+ )
+ return;
+ closePopup();
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open]);
+
+ // Dismiss on Escape
+ React.useEffect(() => {
+ if (!open) return;
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ closePopup();
+ }
+ };
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open]);
+
+ const closePopup = () => {
+ setOpen(false);
+ // Fire onBlur once when popup closes
+ onBlur?.();
+ };
+
+ const openPopup = () => {
+ if (disabled) return;
+ // Re-sync view to current value when opening
+ const p = parseDateValue();
+ if (p) {
+ setViewYear(p.year);
+ setViewMonth(p.month);
+ setHour(p.hour);
+ setMinute(p.minute);
+ }
+ setOpen(true);
+ };
+
+ const togglePopup = () => {
+ if (open) closePopup();
+ else openPopup();
+ };
+
+ // Min/Max date boundaries
+ const minDate = min ? new Date(min + 'T00:00:00') : null;
+ const maxDate = max ? new Date(max + 'T00:00:00') : null;
+
+ const isDayDisabled = (y: number, m: number, d: number) => {
+ const date = new Date(y, m, d);
+ if (minDate && date < minDate) return true;
+ if (maxDate && date > maxDate) return true;
+ return false;
+ };
+
+ // Select a day
+ const selectDay = (day: number) => {
+ if (isDayDisabled(viewYear, viewMonth, day)) return;
+ const dateStr = formatDate(viewYear, viewMonth, day);
+ if (mode === 'datetime') {
+ onChange(`${dateStr}T${pad(hour)}:${pad(minute)}`);
+ } else {
+ onChange(dateStr);
+ closePopup();
+ }
+ };
+
+ const handleTimeChange = (newHour: number, newMinute: number) => {
+ setHour(newHour);
+ setMinute(newMinute);
+ if (parsed) {
+ const dateStr = formatDate(parsed.year, parsed.month, parsed.day);
+ onChange(`${dateStr}T${pad(newHour)}:${pad(newMinute)}`);
+ }
+ };
+
+ // Navigation
+ const prevMonth = () => {
+ if (viewMonth === 0) {
+ setViewMonth(11);
+ setViewYear((y) => y - 1);
+ } else {
+ setViewMonth((m) => m - 1);
+ }
+ };
+
+ const nextMonth = () => {
+ if (viewMonth === 11) {
+ setViewMonth(0);
+ setViewYear((y) => y + 1);
+ } else {
+ setViewMonth((m) => m + 1);
+ }
+ };
+
+ // Build calendar grid
+ const daysInMonth = getDaysInMonth(viewYear, viewMonth);
+ const firstDay = getFirstDayOfWeek(viewYear, viewMonth);
+ const cells: (number | null)[] = [];
+ for (let i = 0; i < firstDay; i++) cells.push(null);
+ for (let d = 1; d <= daysInMonth; d++) cells.push(d);
+
+ const isToday = (d: number) =>
+ d === today.getDate() && viewMonth === today.getMonth() && viewYear === today.getFullYear();
+
+ const isSelected = (d: number) =>
+ parsed !== null && d === parsed.day && viewMonth === parsed.month && viewYear === parsed.year;
+
+ // Display text
+ const displayText = (() => {
+ if (!parsed) return '';
+ const monthName = MONTH_NAMES[parsed.month];
+ const base = `${monthName} ${parsed.day}, ${parsed.year}`;
+ if (mode === 'datetime') {
+ return `${base} ${pad(parsed.hour)}:${pad(parsed.minute)}`;
+ }
+ return base;
+ })();
+
+ // Year options
+ const yearOptions: number[] = [];
+ for (let y = startYear; y <= endYear; y++) yearOptions.push(y);
+
+ return (
+ <>
+ {/* Hidden input for native form validation + autofill */}
+
+ {required && (
+ {}}
+ style={{ position: 'absolute' }}
+ />
+ )}
+
+ {/* Trigger button */}
+
+
+ {/* Popup (portalled) */}
+ {open &&
+ createPortal(
+ e.stopPropagation()}
+ style={{
+ position: 'fixed',
+ top: pos.top,
+ left: pos.left,
+ zIndex: 60,
+ }}
+ className="w-[280px] rounded-lg border border-input bg-card shadow-lg animate-fade-in"
+ >
+ {/* Month/Year Nav */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Day headers */}
+
+ {DAY_HEADERS.map((d) => (
+
+ {d}
+
+ ))}
+
+
+ {/* Day grid */}
+
+ {cells.map((day, i) =>
+ day === null ? (
+
+ ) : (
+
+ )
+ )}
+
+
+ {/* Time selectors (datetime mode only) */}
+ {mode === 'datetime' && (
+
+
+
+ :
+
+
+
+ )}
+
,
+ document.body
+ )}
+ >
+ );
+ }
+);
+DatePicker.displayName = 'DatePicker';
+
+export { DatePicker };
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 1753ba3..9dfb4e7 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -193,6 +193,13 @@
font-weight: 600;
}
+/* ── Chromium native date picker icon fix (safety net) ── */
+input[type="date"]::-webkit-calendar-picker-indicator,
+input[type="datetime-local"]::-webkit-calendar-picker-indicator {
+ filter: invert(1);
+ cursor: pointer;
+}
+
/* ── Form validation — red outline only after submit attempt ── */
form[data-submitted] input:invalid,
form[data-submitted] select:invalid,
@@ -201,6 +208,12 @@ form[data-submitted] textarea:invalid {
box-shadow: 0 0 0 2px hsl(0 62.8% 50% / 0.25);
}
+/* DatePicker trigger inherits red border from its hidden required sibling */
+form[data-submitted] input:invalid + button {
+ border-color: hsl(0 62.8% 50%);
+ box-shadow: 0 0 0 2px hsl(0 62.8% 50% / 0.25);
+}
+
/* ── Ambient background animations ── */
@keyframes drift-1 {