diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx index 6235e92..9b5383a 100644 --- a/frontend/src/components/auth/LockScreen.tsx +++ b/frontend/src/components/auth/LockScreen.tsx @@ -6,6 +6,7 @@ import { useAuth } from '@/hooks/useAuth'; import api, { getErrorMessage } from '@/lib/api'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; @@ -641,13 +642,14 @@ export default function LockScreen() {
- setRegDateOfBirth(e.target.value)} + onChange={(v) => setRegDateOfBirth(v)} required + name="bday" autoComplete="bday" + max={new Date().toISOString().slice(0, 10)} />
diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index c08df8c..792efbb 100644 --- a/frontend/src/components/people/PersonForm.tsx +++ b/frontend/src/components/people/PersonForm.tsx @@ -13,6 +13,7 @@ import { SheetFooter, } from '@/components/ui/sheet'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; @@ -165,11 +166,10 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
- set('birthday', e.target.value)} + onChange={(v) => set('birthday', v)} />
diff --git a/frontend/src/components/projects/ProjectForm.tsx b/frontend/src/components/projects/ProjectForm.tsx index 6d8adc2..ad71109 100644 --- a/frontend/src/components/projects/ProjectForm.tsx +++ b/frontend/src/components/projects/ProjectForm.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'; @@ -121,11 +122,10 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
- setFormData({ ...formData, due_date: e.target.value })} + onChange={(v) => setFormData({ ...formData, due_date: v })} />
diff --git a/frontend/src/components/projects/TaskDetailPanel.tsx b/frontend/src/components/projects/TaskDetailPanel.tsx index f3a36f6..8ee2ebf 100644 --- a/frontend/src/components/projects/TaskDetailPanel.tsx +++ b/frontend/src/components/projects/TaskDetailPanel.tsx @@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { Textarea } from '@/components/ui/textarea'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Select } from '@/components/ui/select'; const taskStatusColors: Record = { @@ -350,10 +351,9 @@ export default function TaskDetailPanel({ Due Date
{isEditing ? ( - setEditState((s) => ({ ...s, due_date: e.target.value }))} + onChange={(v) => setEditState((s) => ({ ...s, due_date: v }))} className="h-8 text-xs" /> ) : ( diff --git a/frontend/src/components/projects/TaskForm.tsx b/frontend/src/components/projects/TaskForm.tsx index 75d6af4..feb3dcf 100644 --- a/frontend/src/components/projects/TaskForm.tsx +++ b/frontend/src/components/projects/TaskForm.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'; @@ -154,11 +155,10 @@ export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate
- setFormData({ ...formData, due_date: e.target.value })} + onChange={(v) => setFormData({ ...formData, due_date: v })} />
diff --git a/frontend/src/components/reminders/ReminderDetailPanel.tsx b/frontend/src/components/reminders/ReminderDetailPanel.tsx index 1d60e03..1358008 100644 --- a/frontend/src/components/reminders/ReminderDetailPanel.tsx +++ b/frontend/src/components/reminders/ReminderDetailPanel.tsx @@ -12,6 +12,7 @@ import { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; import { Button } from '@/components/ui/button'; 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'; @@ -340,11 +341,11 @@ export default function ReminderDetailPanel({
- updateField('remind_at', e.target.value)} + onChange={(v) => updateField('remind_at', v)} className="text-xs" />
diff --git a/frontend/src/components/reminders/ReminderForm.tsx b/frontend/src/components/reminders/ReminderForm.tsx index 435f06d..c6a8d2f 100644 --- a/frontend/src/components/reminders/ReminderForm.tsx +++ b/frontend/src/components/reminders/ReminderForm.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'; @@ -96,11 +97,11 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
- setFormData({ ...formData, remind_at: e.target.value })} + onChange={(v) => setFormData({ ...formData, remind_at: v })} />
diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 6f7d70c..5e480cd 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -18,6 +18,7 @@ import { import { useSettings } from '@/hooks/useSettings'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { DatePicker } from '@/components/ui/date-picker'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; import api from '@/lib/api'; @@ -353,11 +354,10 @@ export default function SettingsPage() {
- setDateOfBirth(e.target.value)} + onChange={(v) => setDateOfBirth(v)} onBlur={() => handleProfileSave('date_of_birth')} onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }} /> diff --git a/frontend/src/components/todos/TodoDetailPanel.tsx b/frontend/src/components/todos/TodoDetailPanel.tsx index ce6c88b..dd30a4c 100644 --- a/frontend/src/components/todos/TodoDetailPanel.tsx +++ b/frontend/src/components/todos/TodoDetailPanel.tsx @@ -13,6 +13,7 @@ import { formatUpdatedAt } from '@/components/shared/utils'; import CopyableField from '@/components/shared/CopyableField'; import { Button } from '@/components/ui/button'; 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 { Checkbox } from '@/components/ui/checkbox'; @@ -385,11 +386,10 @@ export default function TodoDetailPanel({
- updateField('due_date', e.target.value)} + onChange={(v) => updateField('due_date', v)} className="text-xs" />
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 {