diff --git a/apps/portal/src/app/components/AmharicDatePicker.tsx b/apps/portal/src/app/components/AmharicDatePicker.tsx index 717d97b6a..b8151533f 100644 --- a/apps/portal/src/app/components/AmharicDatePicker.tsx +++ b/apps/portal/src/app/components/AmharicDatePicker.tsx @@ -8,6 +8,7 @@ import { Popover, TextInput, } from '@mantine/core'; +import { TimeInput } from '@mantine/dates'; import { useDisclosure } from '@mantine/hooks'; import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic'; import { DayPicker as GregorianDayPicker } from '@daypicker/react'; @@ -65,17 +66,38 @@ const ETH_FORMATTERS = { // midnight — reading back with getUTC*() (not local get*()) keeps the // calendar day stable regardless of the runtime's timezone, avoiding the // same off-by-one class of bug the UTC-noon workaround above exists for. -function parseISO(value?: string | null): Date | null { +function parseISO(value?: string | null, withTime = false): Date | null { if (!value) return null; const d = new Date(value); if (isNaN(d.getTime())) return null; - return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + return withTime + ? d + : new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); } -function formatISO(date: Date): string { - return new Date( - Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()), - ).toISOString(); +function formatISO(date: Date, withTime = false): string { + return withTime + ? date.toISOString() + : new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()), + ).toISOString(); +} + +// Combines a calendar day with a time-of-day, keeping whichever half isn't +// changing. `base` is the currently selected Date (may be null if nothing +// picked yet); `day`/`time` override only the half that's provided. +function mergeDateTime( + base: Date | null, + day?: Date, + time?: { hours: number; minutes: number }, +): Date { + const result = day ? new Date(day) : new Date(base ?? new Date()); + if (time) { + result.setHours(time.hours, time.minutes, 0, 0); + } else if (day && base) { + result.setHours(base.getHours(), base.getMinutes(), base.getSeconds(), 0); + } + return result; } export function toEthiopicDateLabel(date: Date | string): string { @@ -108,6 +130,9 @@ export interface AmharicDatePickerProps { size?: MantineSize; name?: string; onBlur?: () => void; + /** Show a time-of-day field alongside the calendar. Off by default — + * most callers only need a calendar day. */ + withTime?: boolean; } export function AmharicDatePicker({ @@ -121,6 +146,7 @@ export function AmharicDatePicker({ size, name, onBlur, + withTime = false, }: AmharicDatePickerProps) { const { t, i18n } = useTranslation(); const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>(() => @@ -128,9 +154,9 @@ export function AmharicDatePicker({ ); const [opened, { close, toggle }] = useDisclosure(false); - const selected = parseISO(value); + const selected = parseISO(value, withTime); - const displayValue = selected + const dateLabel = selected ? calendarType === 'EN' ? selected.toLocaleDateString('en-US', { year: 'numeric', @@ -139,6 +165,11 @@ export function AmharicDatePicker({ }) : toAmharicDisplay(selected) : ''; + const timeLabel = + withTime && selected + ? ` ${selected.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}` + : ''; + const displayValue = dateLabel + timeLabel; return ( { - onChange?.(date ? formatISO(date) : ''); + onChange?.(date ? formatISO(mergeDateTime(selected, date), withTime) : ''); close(); }} /> @@ -212,12 +243,32 @@ export function AmharicDatePicker({ endMonth={YEAR_DROPDOWN_END} captionLayout="dropdown" onSelect={(date: Date | undefined) => { - onChange?.(date ? formatISO(date) : ''); + onChange?.(date ? formatISO(mergeDateTime(selected, date), withTime) : ''); close(); }} /> )} + {withTime && ( + { + const [h, m] = e.currentTarget.value.split(':').map(Number); + if (Number.isNaN(h) || Number.isNaN(m)) return; + onChange?.( + formatISO(mergeDateTime(selected, undefined, { hours: h, minutes: m }), true), + ); + }} + /> + )} +