- updateField('start_datetime', e.target.value)}
+ onChange={(v) => updateField('start_datetime', v)}
className="text-xs"
required
/>
- updateField('end_datetime', e.target.value)}
+ onChange={(v) => updateField('end_datetime', v)}
className="text-xs"
/>
diff --git a/frontend/src/components/calendar/EventForm.tsx b/frontend/src/components/calendar/EventForm.tsx
index f3168e3..4bae0fc 100644
--- a/frontend/src/components/calendar/EventForm.tsx
+++ b/frontend/src/components/calendar/EventForm.tsx
@@ -13,6 +13,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';
@@ -281,22 +282,24 @@ export default function EventForm({ event, templateData, templateName, initialSt
diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx
index c08df8c..2683ae2 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,11 @@ 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..7148a10 100644
--- a/frontend/src/components/projects/ProjectForm.tsx
+++ b/frontend/src/components/projects/ProjectForm.tsx
@@ -12,11 +12,18 @@ 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';
import { Button } from '@/components/ui/button';
+function todayLocal(): string {
+ const d = new Date();
+ const pad = (n: number) => n.toString().padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
interface ProjectFormProps {
project: Project | null;
onClose: () => void;
@@ -28,7 +35,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
name: project?.name || '',
description: project?.description || '',
status: project?.status || 'not_started',
- due_date: project?.due_date ? project.due_date.slice(0, 10) : '',
+ due_date: project?.due_date ? project.due_date.slice(0, 10) : todayLocal(),
});
const mutation = useMutation({
@@ -121,11 +128,11 @@ 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..2f7c144 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
= {
@@ -59,6 +60,12 @@ interface EditState {
description: string;
}
+function todayLocal(): string {
+ const d = new Date();
+ const pad = (n: number) => n.toString().padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
function buildEditState(task: ProjectTask): EditState {
return {
title: task.title,
@@ -83,7 +90,7 @@ export default function TaskDetailPanel({
const [commentText, setCommentText] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [editState, setEditState] = useState(() =>
- task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: '', person_id: '', description: '' }
+ task ? buildEditState(task) : { title: '', status: 'pending', priority: 'none', due_date: todayLocal(), person_id: '', description: '' }
);
const { data: people = [] } = useQuery({
@@ -350,10 +357,10 @@ 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..9cf4385 100644
--- a/frontend/src/components/projects/TaskForm.tsx
+++ b/frontend/src/components/projects/TaskForm.tsx
@@ -12,11 +12,18 @@ 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';
import { Button } from '@/components/ui/button';
+function todayLocal(): string {
+ const d = new Date();
+ const pad = (n: number) => n.toString().padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
interface TaskFormProps {
projectId: number;
task: ProjectTask | null;
@@ -32,7 +39,7 @@ export default function TaskForm({ projectId, task, parentTaskId, defaultDueDate
description: task?.description || '',
status: task?.status || 'pending',
priority: task?.priority || 'medium',
- due_date: task?.due_date ? task.due_date.slice(0, 10) : (!task && defaultDueDate ? defaultDueDate.slice(0, 10) : ''),
+ due_date: task?.due_date ? task.due_date.slice(0, 10) : (!task && defaultDueDate ? defaultDueDate.slice(0, 10) : todayLocal()),
person_id: task?.person_id?.toString() || '',
});
@@ -154,11 +161,11 @@ 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..e6dfe6a 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';
@@ -39,6 +40,12 @@ const recurrenceLabels: Record
= {
monthly: 'Monthly',
};
+function nowLocal(): string {
+ const d = new Date();
+ const pad = (n: number) => n.toString().padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
const QUERY_KEYS = [['reminders'], ['dashboard'], ['upcoming']] as const;
function buildEditState(reminder: Reminder): EditState {
@@ -54,7 +61,7 @@ function buildCreateState(): EditState {
return {
title: '',
description: '',
- remind_at: '',
+ remind_at: nowLocal(),
recurrence_rule: '',
};
}
@@ -340,11 +347,12 @@ 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..a95e560 100644
--- a/frontend/src/components/reminders/ReminderForm.tsx
+++ b/frontend/src/components/reminders/ReminderForm.tsx
@@ -12,11 +12,18 @@ 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';
import { Button } from '@/components/ui/button';
+function nowLocal(): string {
+ const d = new Date();
+ const pad = (n: number) => n.toString().padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
interface ReminderFormProps {
reminder: Reminder | null;
onClose: () => void;
@@ -27,7 +34,7 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
const [formData, setFormData] = useState({
title: reminder?.title || '',
description: reminder?.description || '',
- remind_at: reminder?.remind_at ? reminder.remind_at.slice(0, 16) : '',
+ remind_at: reminder?.remind_at ? reminder.remind_at.slice(0, 16) : nowLocal(),
recurrence_rule: reminder?.recurrence_rule || '',
});
@@ -96,11 +103,12 @@ 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 6137f9b..6019c78 100644
--- a/frontend/src/components/settings/SettingsPage.tsx
+++ b/frontend/src/components/settings/SettingsPage.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
-import { useQueryClient } from '@tanstack/react-query';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
Settings,
User,
@@ -18,10 +18,11 @@ 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';
-import type { GeoLocation } from '@/types';
+import type { GeoLocation, UserProfile } from '@/types';
import { Switch } from '@/components/ui/switch';
import TotpSetupSection from './TotpSetupSection';
import NtfySettingsSection from './NtfySettingsSection';
@@ -54,6 +55,29 @@ export default function SettingsPage() {
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
const [autoLockMinutes, setAutoLockMinutes] = useState
(settings?.auto_lock_minutes ?? 5);
+ // Profile fields (stored on User model, fetched from /auth/profile)
+ const profileQuery = useQuery({
+ queryKey: ['profile'],
+ queryFn: async () => {
+ const { data } = await api.get('/auth/profile');
+ return data;
+ },
+ });
+ const [firstName, setFirstName] = useState('');
+ const [lastName, setLastName] = useState('');
+ const [profileEmail, setProfileEmail] = useState('');
+ const [dateOfBirth, setDateOfBirth] = useState('');
+ const [emailError, setEmailError] = useState(null);
+
+ useEffect(() => {
+ if (profileQuery.data) {
+ setFirstName(profileQuery.data.first_name ?? '');
+ setLastName(profileQuery.data.last_name ?? '');
+ setProfileEmail(profileQuery.data.email ?? '');
+ setDateOfBirth(profileQuery.data.date_of_birth ?? '');
+ }
+ }, [profileQuery.dataUpdatedAt]);
+
// Sync state when settings load
useEffect(() => {
if (settings) {
@@ -149,6 +173,35 @@ export default function SettingsPage() {
}
};
+ const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => {
+ const values: Record = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth };
+ const current = values[field].trim();
+ const original = profileQuery.data?.[field] ?? '';
+ if (current === (original || '')) return;
+
+ // Client-side email validation
+ if (field === 'email' && current) {
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(current)) {
+ setEmailError('Invalid email format');
+ return;
+ }
+ }
+ setEmailError(null);
+
+ try {
+ await api.put('/auth/profile', { [field]: current || null });
+ queryClient.invalidateQueries({ queryKey: ['profile'] });
+ toast.success('Profile updated');
+ } catch (err: any) {
+ const detail = err?.response?.data?.detail;
+ if (field === 'email' && detail) {
+ setEmailError(typeof detail === 'string' ? detail : 'Failed to update email');
+ } else {
+ toast.error(typeof detail === 'string' ? detail : 'Failed to update profile');
+ }
+ }
+ };
+
const handleColorChange = async (color: string) => {
setSelectedColor(color);
try {
@@ -233,11 +286,11 @@ export default function SettingsPage() {
Profile
- Personalize how UMBRA greets you
+ Your profile and display preferences
-
+
+
+
+
+
{ setProfileEmail(e.target.value); setEmailError(null); }}
+ onBlur={() => handleProfileSave('email')}
+ onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('email'); }}
+ maxLength={254}
+ className={emailError ? 'border-red-500/50' : ''}
+ />
+ {emailError && (
+
{emailError}
+ )}
+
+
+
+ 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..8b6a503 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';
@@ -57,6 +58,18 @@ const recurrenceLabels: Record = {
monthly: 'Monthly',
};
+function todayLocal(): string {
+ const d = new Date();
+ const pad = (n: number) => n.toString().padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+}
+
+function nowTimeLocal(): string {
+ const d = new Date();
+ const pad = (n: number) => n.toString().padStart(2, '0');
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
const QUERY_KEYS = [['todos'], ['dashboard'], ['upcoming']] as const;
function buildEditState(todo: Todo): EditState {
@@ -76,8 +89,8 @@ function buildCreateState(defaults?: TodoCreateDefaults | null): EditState {
title: '',
description: '',
priority: 'medium',
- due_date: '',
- due_time: '',
+ due_date: todayLocal(),
+ due_time: nowTimeLocal(),
category: defaults?.category || '',
recurrence_rule: '',
};
@@ -385,41 +398,34 @@ export default function TodoDetailPanel({
-
-
-
-
-
{/* Save / Cancel at bottom */}
diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx
new file mode 100644
index 0000000..b846ad2
--- /dev/null
+++ b/frontend/src/components/ui/date-picker.tsx
@@ -0,0 +1,538 @@
+import * as React from 'react';
+import { createPortal } from 'react-dom';
+import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+// ── Browser detection (stable — checked once at module load) ──
+
+const isFirefox = typeof navigator !== 'undefined' && /Firefox\//i.test(navigator.userAgent);
+
+// ── 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)}`;
+}
+
+function to12Hour(h24: number): { hour: number; ampm: 'AM' | 'PM' } {
+ if (h24 === 0) return { hour: 12, ampm: 'AM' };
+ if (h24 < 12) return { hour: h24, ampm: 'AM' };
+ if (h24 === 12) return { hour: 12, ampm: 'PM' };
+ return { hour: h24 - 12, ampm: 'PM' };
+}
+
+function to24Hour(h12: number, ampm: string): number {
+ const isPM = ampm.toUpperCase() === 'PM';
+ if (h12 === 12) return isPM ? 12 : 0;
+ return isPM ? h12 + 12 : h12;
+}
+
+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'];
+const HOUR_OPTIONS = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
+
+// ── Props ──
+
+export interface DatePickerProps {
+ variant?: 'button' | 'input';
+ 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(
+ (
+ {
+ variant = 'button',
+ 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 ISO value into parts
+ 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);
+
+ // Refs
+ const triggerRef = React.useRef(null);
+ const wrapperRef = React.useRef(null);
+ const inputElRef = React.useRef(null);
+ const popupRef = React.useRef(null);
+ const blurTimeoutRef = React.useRef>();
+
+ const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 });
+
+ React.useImperativeHandle(ref, () => triggerRef.current!);
+
+ // Sync popup view state when value changes (only when popup is closed)
+ React.useEffect(() => {
+ if (open) return;
+ 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, open]);
+
+ // Position popup
+ // Firefox + input variant falls through to button variant, so use triggerRef
+ const usesNativeInput = variant === 'input' && !isFirefox;
+ const updatePosition = React.useCallback(() => {
+ const el = usesNativeInput ? wrapperRef.current : triggerRef.current;
+ if (!el) return;
+ const rect = el.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),
+ });
+ }, [mode, usesNativeInput]);
+
+ React.useLayoutEffect(() => {
+ 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]);
+
+ const closePopup = React.useCallback(
+ (refocusTrigger = true) => {
+ setOpen(false);
+ if (!usesNativeInput) {
+ onBlur?.();
+ } else if (refocusTrigger) {
+ setTimeout(() => inputElRef.current?.focus(), 0);
+ }
+ },
+ [usesNativeInput, onBlur]
+ );
+
+ // Dismiss on click outside
+ React.useEffect(() => {
+ if (!open) return;
+ const handler = (e: MouseEvent) => {
+ if (popupRef.current?.contains(e.target as Node)) return;
+ if (!usesNativeInput && triggerRef.current?.contains(e.target as Node)) return;
+ if (usesNativeInput && wrapperRef.current?.contains(e.target as Node)) return;
+ closePopup(false);
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, [open, usesNativeInput, closePopup]);
+
+ // Dismiss on Escape
+ React.useEffect(() => {
+ if (!open) return;
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.stopPropagation();
+ closePopup(true);
+ }
+ };
+ document.addEventListener('keydown', handler);
+ return () => document.removeEventListener('keydown', handler);
+ }, [open, closePopup]);
+
+ // Input variant: smart blur — only fires when focus truly leaves the component
+ const handleInputBlur = React.useCallback(() => {
+ if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);
+ blurTimeoutRef.current = setTimeout(() => {
+ const active = document.activeElement;
+ if (
+ popupRef.current?.contains(active) ||
+ wrapperRef.current?.contains(active)
+ ) return;
+ onBlur?.();
+ }, 10);
+ }, [onBlur]);
+
+ React.useEffect(() => {
+ return () => {
+ if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);
+ };
+ }, []);
+
+ const openPopup = () => {
+ if (disabled) return;
+ const p = parseDateValue();
+ if (p) {
+ setViewYear(p.year);
+ setViewMonth(p.month);
+ setHour(p.hour);
+ setMinute(p.minute);
+ }
+ setOpen(true);
+ };
+
+ const togglePopup = () => {
+ if (open) closePopup(true);
+ else openPopup();
+ };
+
+ // Min/Max 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;
+ };
+
+ 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(true);
+ }
+ };
+
+ 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)}`);
+ }
+ };
+
+ 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);
+ };
+
+ // 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 isTodayDay = (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;
+
+ // 12-hour display values for time selectors
+ const { hour: h12, ampm: currentAmpm } = to12Hour(hour);
+
+ // Button variant display text
+ const displayText = (() => {
+ if (!parsed) return '';
+ const monthName = MONTH_NAMES[parsed.month];
+ const base = `${monthName} ${parsed.day}, ${parsed.year}`;
+ if (mode === 'datetime') {
+ const { hour: dh, ampm: da } = to12Hour(parsed.hour);
+ return `${base} ${dh}:${pad(parsed.minute)} ${da}`;
+ }
+ return base;
+ })();
+
+ // Year options
+ const yearOptions: number[] = [];
+ for (let y = startYear; y <= endYear; y++) yearOptions.push(y);
+
+ // ── Shared popup ──
+ const popup = 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 — 12-hour with AM/PM */}
+ {mode === 'datetime' && (
+
+
+
+ :
+
+
+
+
+ )}
+
,
+ document.body
+ )
+ : null;
+
+ // ── Input variant (Chromium only) ──
+ // Firefox: falls through to the button variant below because Firefox has no
+ // CSS pseudo-element to hide its native calendar icon (Mozilla bug 1830890).
+ // Chromium: uses native type="date"/"datetime-local" for segmented editing UX,
+ // with the native icon hidden via CSS in index.css (.datepicker-wrapper rule).
+ if (variant === 'input' && !isFirefox) {
+ return (
+ <>
+
+ onChange(e.target.value)}
+ onBlur={handleInputBlur}
+ onKeyDown={(e) => {
+ if (open && e.key === 'Enter') {
+ e.preventDefault();
+ return;
+ }
+ onKeyDown?.(e);
+ }}
+ required={required}
+ disabled={disabled}
+ min={min}
+ max={max}
+ className={cn(
+ 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 pr-9 text-sm ring-offset-background 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',
+ className
+ )}
+ />
+
+
+ {popup}
+ >
+ );
+ }
+
+ // ── Button variant: non-editable trigger (registration DOB) ──
+ return (
+ <>
+
+ {required && (
+ {}}
+ style={{ position: 'absolute' }}
+ />
+ )}
+
+
+
+ {popup}
+ >
+ );
+ }
+);
+DatePicker.displayName = 'DatePicker';
+
+export { DatePicker };
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
index 7c181f5..a7acc78 100644
--- a/frontend/src/hooks/useAuth.ts
+++ b/frontend/src/hooks/useAuth.ts
@@ -47,8 +47,13 @@ export function useAuth() {
});
const registerMutation = useMutation({
- mutationFn: async ({ username, password }: { username: string; password: string }) => {
- const { data } = await api.post('/auth/register', { username, password });
+ mutationFn: async ({ username, password, email, date_of_birth, preferred_name }: {
+ username: string; password: string; email: string; date_of_birth: string;
+ preferred_name?: string;
+ }) => {
+ const payload: Record = { username, password, email, date_of_birth };
+ if (preferred_name) payload.preferred_name = preferred_name;
+ const { data } = await api.post('/auth/register', payload);
return data;
},
onSuccess: (data) => {
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 1753ba3..8718d43 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -193,6 +193,22 @@
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;
+}
+
+/* Hide native picker icon inside DatePicker wrapper (custom icon replaces it) */
+/* Chromium: remove the native calendar icon entirely */
+.datepicker-wrapper input::-webkit-calendar-picker-indicator {
+ display: none;
+}
+/* Firefox: No CSS pseudo-element exists to hide the native calendar icon.
+ The custom button covers the native icon zone with a solid background so
+ only one icon is visible regardless of browser. */
+
/* ── Form validation — red outline only after submit attempt ── */
form[data-submitted] input:invalid,
form[data-submitted] select:invalid,
@@ -201,6 +217,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 {
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index fed8531..20e209f 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -237,6 +237,7 @@ export interface AdminUser {
export interface AdminUserDetail extends AdminUser {
active_sessions: number;
preferred_name?: string | null;
+ date_of_birth?: string | null;
must_change_password?: boolean;
locked_until?: string | null;
}
@@ -345,6 +346,14 @@ export interface UpcomingResponse {
cutoff_date: string;
}
+export interface UserProfile {
+ username: string;
+ email: string | null;
+ first_name: string | null;
+ last_name: string | null;
+ date_of_birth: string | null;
+}
+
export interface EventTemplate {
id: number;
name: string;