import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ActionIcon, Button, Group, MantineSize, 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'; import { IconCalendarEvent } from '@tabler/icons-react'; import { EthDateTime } from 'ethiopian-calendar-date-converter'; import '@daypicker/react/dist/style.css'; import './AmharicDatePicker.css'; const EC_MONTHS_AM = [ 'መስከረም', 'ጥቅምት', 'ኅዳር', 'ታህሳስ', 'ጥር', 'የካቲት', 'መጋቢት', 'ሚያዝያ', 'ግንቦት', 'ሰኔ', 'ሐምሌ', 'ነሐሴ', 'ጳጉሜ', ]; // EthDateTime.fromEuropeanDate() computes the day from a raw UTC-epoch // difference. A local-midnight Date in any positive-UTC-offset timezone // (e.g. Ethiopia, UTC+3) lands in the previous UTC day and converts to // yesterday's Ethiopian date. Re-embedding the same Y/M/D at UTC noon fixes // the day regardless of the runtime's timezone. function toEthDateTime(date: Date): EthDateTime { const utcNoon = new Date( Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12), ); return EthDateTime.fromEuropeanDate(utcNoon); } function ethMonthName(date: Date): string { try { return EC_MONTHS_AM[toEthDateTime(date).month - 1] ?? ''; } catch { return ''; } } function toAmharicDisplay(date: Date): string { try { const eth = toEthDateTime(date); return `${ethMonthName(date)} ${eth.date}/${eth.year}`; } catch { return date.toLocaleDateString('en-US'); } } // react-day-picker calls these with the Gregorian Date it tracks internally; // override so the caption/dropdown show Amharic month names instead of the // library's Latin transliteration (triggered by numerals="latn" below). const ETH_FORMATTERS = { formatCaption: (month: Date) => `${ethMonthName(month)} ${toEthDateTime(month).year}`, formatMonthDropdown: (month: Date) => ethMonthName(month), }; type DateWireFormat = 'iso' | 'date'; // yyyy-MM-dd, parsed/formatted as a LOCAL calendar date — no Date-object/UTC // round-trip at all, so it can't suffer the timezone off-by-one class of bug // the ISO path below works around. This is the shape filter query params and // plain `date: string` DTO fields expect. function parsePlainDate(value: string): Date | null { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!m) return null; return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); } function formatPlainDate(date: Date): string { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } // ISO wire format is a full ISO-8601 instant string (e.g. // "2026-07-31T00:00:00.000Z"), what most backend date fields expect. When // `withTime` is off, the picker only selects a calendar day, so the time is // pinned to UTC 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 parseWireValue( value: string | null | undefined, format: DateWireFormat, withTime: boolean, ): Date | null { if (!value) return null; if (format === 'date') return parsePlainDate(value); const d = new Date(value); if (isNaN(d.getTime())) return null; return withTime ? d : new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); } function formatWireValue( date: Date, format: DateWireFormat, withTime: boolean, ): string { if (format === 'date') return formatPlainDate(date); 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; } // Accepts either wire shape for display-only formatting — tries the plain // yyyy-MM-dd shape first, falls back to ISO — so these keep working // regardless of which `dateFormat` produced the stored string. function parseAnyDateString(value: string): Date | null { return parsePlainDate(value) ?? parseWireValue(value, 'iso', false); } export function toEthiopicDateLabel(date: Date | string): string { const d = typeof date === 'string' ? parseAnyDateString(date) : date; if (!d) return ''; try { const eth = toEthDateTime(d); return `EC ${eth.year}-${String(eth.month).padStart(2, '0')}-${String(eth.date).padStart(2, '0')}`; } catch { return d.toLocaleDateString('en-US'); } } export function toGregorianDateLabel(date: Date | string): string { const d = typeof date === 'string' ? parseAnyDateString(date) : date; if (!d) return ''; return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); } const YEAR_DROPDOWN_START = new Date(new Date().getFullYear() - 100, 0, 1); const YEAR_DROPDOWN_END = new Date(new Date().getFullYear() + 50, 11, 31); export interface AmharicDatePickerProps { label?: React.ReactNode; value?: string | null; onChange?: (value: string) => void; required?: boolean; placeholder?: string; error?: React.ReactNode; disabled?: boolean; size?: MantineSize; name?: string; onBlur?: () => void; /** Width of the input, same as Mantine's `w` on TextInput/Select — needed * to line this field up with siblings in a filter bar. */ w?: number | string; /** Show a time-of-day field alongside the calendar. Off by default — * most callers only need a calendar day. */ withTime?: boolean; /** Wire format for `value`/`onChange`: a full ISO-8601 instant (default, * what most backend date fields expect) or a bare `yyyy-mm-dd` calendar * date (what filter query params and plain `date: string` DTO fields * expect). */ dateFormat?: DateWireFormat; } export function AmharicDatePicker({ label, value, onChange, required, placeholder, error, disabled, size, name, onBlur, w, withTime = false, dateFormat = 'iso', }: AmharicDatePickerProps) { const { t, i18n } = useTranslation(); const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>(() => i18n.language?.startsWith('am') ? 'AMH' : 'EN', ); const [opened, { close, toggle }] = useDisclosure(false); const selected = parseWireValue(value, dateFormat, withTime); const dateLabel = selected ? calendarType === 'EN' ? selected.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', }) : toAmharicDisplay(selected) : ''; const timeLabel = withTime && selected ? ` ${selected.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}` : ''; const displayValue = dateLabel + timeLabel; return ( — a different scroll container than its reference. // The default `absolute` strategy sums offsets across that scroll chain // and can get it wrong (dropdown flipped off-screen, or not following // the field as the modal scrolls). `fixed` positions purely off the // reference's viewport rect, sidestepping that. floatingStrategy="fixed" // Prevents the dropdown from re-flipping position mid-interaction — // switching months resizes the grid (Pagume has far fewer days), which // otherwise nudges the floating box right as a nav click lands, making // the click miss. preventPositionChangeWhenVisible > !disabled && toggle()} onBlur={onBlur} leftSection={ } leftSectionWidth="calc(4.375rem * var(--mantine-scale))" rightSection={ toggle()}> } /> {/* Inside a Modal there may not be room below the field for the full calendar + time input + action row — scroll the dropdown itself rather than relying on the page (which doesn't scroll while a Modal is open) or the Modal body (the dropdown is portaled out of it, so its scroll never reaches this). */} {calendarType === 'AMH' ? ( { onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : ''); if (!withTime) close(); }} /> ) : ( { onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : ''); if (!withTime) close(); }} /> )} {withTime && ( { const [h, m] = e.currentTarget.value.split(':').map(Number); if (Number.isNaN(h) || Number.isNaN(m)) return; onChange?.( formatWireValue(mergeDateTime(selected, undefined, { hours: h, minutes: m }), dateFormat, true), ); }} /> )} ); }