diff --git a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel.tsx b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel.tsx index aa93be2e1..ecbd5dac9 100644 --- a/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel.tsx +++ b/apps/backoffice/src/app/features/exam/components/ExamIncidentsPanel.tsx @@ -16,6 +16,7 @@ import { } from '@mantine/core'; import { IconAlertTriangle, IconInfoCircle, IconPlus } from '@tabler/icons-react'; import { notify } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; import { extractErrorMessage } from '@ema-platform/api'; import { useGetExamIncidentsQuery, @@ -51,6 +52,7 @@ const STATUS_COLOR: Record = { */ export function ExamIncidentsPanel({ examId }: { examId: string }) { const { t } = useTranslation(); + const showDate = useDateDisplayer(); const { data: incidents, isError } = useGetExamIncidentsQuery(examId); const { data: registrations } = useGetExamRegistrationsQuery(examId); const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation(); @@ -175,7 +177,7 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) { )} - {incident.occurredAt?.slice(0, 10)} + {showDate(incident.occurredAt)} = { DRAFT: 'gray', @@ -13,6 +14,7 @@ export function ItemTable() { const { data, isLoading } = useGetItemsQuery({}); const [deleteItem] = useDeleteItemMutation(); const { handleError } = useErrorHandler(); + const showDate = useDateDisplayer(); const handleDelete = async (id: string) => { try { @@ -43,7 +45,7 @@ export function ItemTable() { {item.status} - {new Date(item.createdAt).toLocaleDateString()} + {showDate(item.createdAt)} (null); const items = data?.items ?? []; + const showDate = useDateDisplayer(); return ( @@ -227,12 +229,12 @@ export function LicenseRegisterPage() { - {license.issueDate?.slice(0, 10)} + {showDate(license.issueDate)} - {license.expiryDate?.slice(0, 10)} + {showDate(license.expiryDate)} diff --git a/apps/backoffice/src/app/features/license-review/components/ActivityRail.tsx b/apps/backoffice/src/app/features/license-review/components/ActivityRail.tsx index c0913ceb2..cca0d8535 100644 --- a/apps/backoffice/src/app/features/license-review/components/ActivityRail.tsx +++ b/apps/backoffice/src/app/features/license-review/components/ActivityRail.tsx @@ -20,6 +20,7 @@ import { STATUS_LABELS, type ApplicationDetail, } from '@ema-platform/api'; +import { useDateDisplayer } from '@ema-platform/shared'; type EntryKind = 'status' | 'remark' | 'upload' | 'assignment'; @@ -50,7 +51,8 @@ const ICONS: Record = { * notifications sent to the applicant are not among them. */ export function ActivityRail({ detail }: { detail: ApplicationDetail }) { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); + const showDate = useDateDisplayer(); const entries = useMemo(() => { const merged: ActivityEntry[] = []; @@ -159,12 +161,9 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) { · - + - {new Date(entry.at).toLocaleDateString(i18n.language)} + {showDate(entry.at.slice(0, 10))} diff --git a/apps/backoffice/src/app/features/license-review/export.ts b/apps/backoffice/src/app/features/license-review/export.ts index 92c1dc0e2..e38e92105 100644 --- a/apps/backoffice/src/app/features/license-review/export.ts +++ b/apps/backoffice/src/app/features/license-review/export.ts @@ -1,4 +1,5 @@ import { STATUS_LABELS, type LicenseApplication } from '@ema-platform/api'; +import { dateDisplayer } from '@ema-platform/shared'; import { computeSla } from './sla'; /** @@ -27,15 +28,13 @@ const COLUMNS: Array<{ { header: 'Assigned officer', value: (a) => a.assignedOfficerId }, { header: 'Submitted', - value: (a, locale) => - a.submittedAt ? new Date(a.submittedAt).toLocaleString(locale) : '', + value: (a, locale) => (a.submittedAt ? dateDisplayer(a.submittedAt, locale) : ''), }, { header: 'Decided', - value: (a, locale) => - a.decidedAt ? new Date(a.decidedAt).toLocaleString(locale) : '', + value: (a, locale) => (a.decidedAt ? dateDisplayer(a.decidedAt, locale) : ''), }, - { header: 'SLA', value: (a) => computeSla(a).label }, + { header: 'SLA', value: (a, locale) => computeSla(a, undefined, locale).label }, { header: 'Adjustment rounds', value: (a) => a.adjustmentRound }, { header: 'Declared capital', value: (a) => a.capitalAmountDeclared }, { header: 'Verified capital', value: (a) => a.capitalAmountVerified }, diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx index 8b7c33c47..a566e6a6e 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseQueuePage.tsx @@ -56,6 +56,7 @@ import { AmharicDatePicker, type AdvancedColumn, } from "@ema-platform/ui"; +import { dateDisplayer } from "@ema-platform/shared"; import { computeSla } from "../sla"; import { DEFAULT_VIEW, @@ -428,18 +429,14 @@ export function LicenseQueuePage() { label: t("queue.submitted", "Submitted"), cell: ({ row }) => ( - {row.original.submittedAt - ? new Date(row.original.submittedAt).toLocaleDateString( - i18n.language, - ) - : "—"} + {dateDisplayer(row.original.submittedAt, i18n.language)} ), }, { header: t("queue.sla", "Age / SLA"), cell: ({ row }) => { - const sla = computeSla(row.original); + const sla = computeSla(row.original, undefined, i18n.language); return ( // Colour is never the only signal — the label says the same thing. diff --git a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx index 6e6ae7918..43c23f731 100644 --- a/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx +++ b/apps/backoffice/src/app/features/license-review/pages/LicenseReviewPage.tsx @@ -59,6 +59,7 @@ import { type RemarkTargetType, } from '@ema-platform/api'; import { ErrorState, ModalFooter, AmharicDatePicker } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; import { usePermissions } from '@ema-platform/auth'; import { useAppSelector } from '../../../store/hooks'; import { DecisionBar } from '../components/DecisionBar'; @@ -109,6 +110,7 @@ function buildChecklist( */ export function LicenseReviewPage() { const { t, i18n } = useTranslation(); + const showDate = useDateDisplayer(); const { id = '' } = useParams(); const { can } = usePermissions(); const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? ''; @@ -268,7 +270,7 @@ export function LicenseReviewPage() { const app = data.application; const status = app.status; const presentation = presentationFor(app.licenseType?.key); - const sla = computeSla(app); + const sla = computeSla(app, undefined, i18n.language); const eligibility = evaluateEligibility(app, app.licenseType, i18n.language); const rawThreshold = app.licenseType?.capitalThreshold; @@ -536,11 +538,7 @@ export function LicenseReviewPage() { @@ -604,7 +602,7 @@ export function LicenseReviewPage() { } > - {new Date(entry.createdAt).toLocaleDateString(i18n.language)} + {showDate(entry.createdAt)} ))} @@ -848,7 +846,7 @@ export function LicenseReviewPage() {
{inspection.scheduledDate - ? new Date(inspection.scheduledDate).toLocaleString(i18n.language) + ? showDate(inspection.scheduledDate) : t('review.unscheduled', 'Not scheduled')} {inspection.findings && ( diff --git a/apps/backoffice/src/app/features/license-review/sla.ts b/apps/backoffice/src/app/features/license-review/sla.ts index eba525cb6..9e3f98de3 100644 --- a/apps/backoffice/src/app/features/license-review/sla.ts +++ b/apps/backoffice/src/app/features/license-review/sla.ts @@ -1,4 +1,5 @@ import type { LicenseApplication } from '@ema-platform/api'; +import { dateDisplayer } from '@ema-platform/shared'; /** Amber once this much of the window has been consumed. */ const WARNING_RATIO = 0.7; @@ -35,6 +36,7 @@ function formatDuration(ms: number): string { export function computeSla( application: LicenseApplication, now: number = Date.now(), + language = 'en', ): SlaState { const slaHours = application.licenseType?.slaHours; const submittedAt = application.submittedAt; @@ -54,7 +56,7 @@ export function computeSla( const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted; const window = slaHours * HOUR_MS; const ratio = Math.min(Math.max(elapsed / window, 0), 1); - const targetText = `Target ${slaHours}h from submission (${new Date(target).toLocaleString()})`; + const targetText = `Target ${slaHours}h from submission (${dateDisplayer(target, language)})`; if (application.decidedAt) { const met = elapsed <= window; diff --git a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx index c69a9db76..d7b2b88de 100644 --- a/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx +++ b/apps/backoffice/src/app/features/medical-verification/pages/MedicalVerificationPage.tsx @@ -24,6 +24,7 @@ import { IconX, } from '@tabler/icons-react'; import { AdvancedTable, notify, type AdvancedColumn } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; import { extractErrorMessage, useGetAttachmentsQuery, @@ -216,6 +217,8 @@ export function MedicalVerificationPage() { title: string; } | null>(null); + const showDate = useDateDisplayer(); + const [medicalPage, setMedicalPage] = useState(0); const [seaServicePage, setSeaServicePage] = useState(0); const [medicalPageSize, setMedicalPageSize] = useState(PAGE_SIZE); @@ -301,7 +304,7 @@ export function MedicalVerificationPage() { label: 'Validity', cell: ({ row }) => ( - {row.original.issueDate} → {row.original.expiryDate} + {showDate(row.original.issueDate)} → {showDate(row.original.expiryDate)} ), }, @@ -367,7 +370,7 @@ export function MedicalVerificationPage() { ), }, ], - [rulingMedical, rule, verifyMedical], + [rulingMedical, rule, verifyMedical, showDate], ); const seaServiceColumns: AdvancedColumn[] = useMemo( @@ -415,7 +418,7 @@ export function MedicalVerificationPage() { label: 'Period', cell: ({ row }) => ( - {row.original.engagementDate} → {row.original.dischargeDate} + {showDate(row.original.engagementDate)} → {showDate(row.original.dischargeDate)} ), }, @@ -471,7 +474,7 @@ export function MedicalVerificationPage() { ), }, ], - [rulingSeaService, rule, verifySeaService], + [rulingSeaService, rule, verifySeaService, showDate], ); return ( diff --git a/apps/backoffice/src/app/features/result/pages/ExamAppealsPage.tsx b/apps/backoffice/src/app/features/result/pages/ExamAppealsPage.tsx index 2608547c3..444aedfad 100644 --- a/apps/backoffice/src/app/features/result/pages/ExamAppealsPage.tsx +++ b/apps/backoffice/src/app/features/result/pages/ExamAppealsPage.tsx @@ -18,6 +18,7 @@ import { } from '@mantine/core'; import { IconGavel, IconInfoCircle } from '@tabler/icons-react'; import { notify } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; import { extractErrorMessage } from '@ema-platform/api'; import { useGetPendingAppealsQuery, @@ -35,6 +36,7 @@ import type { ExamAppeal } from '../types/result'; export function ExamAppealsPage() { const { t, i18n } = useTranslation(); const locale = i18n.language as 'en' | 'am'; + const showDate = useDateDisplayer(); const { data: appeals, isLoading, isError } = useGetPendingAppealsQuery(); const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation(); @@ -128,7 +130,7 @@ export function ExamAppealsPage() { - {appeal.createdAt?.slice(0, 10)} + {showDate(appeal.createdAt)}
@@ -253,7 +255,7 @@ export function ExamsPage() {
{registration.exam?.title?.en ?? '—'} - {registration.exam?.date?.slice(0, 10)} + {showDate(registration.exam?.date)} {registration.exam?.venue ?? '—'} {result.exam?.title?.en ?? '—'} - {result.publishedAt?.slice(0, 10) ?? '—'} + {showDate(result.publishedAt)} {result.totalScore} diff --git a/apps/portal/src/app/features/licensing/components/LicenseCard.tsx b/apps/portal/src/app/features/licensing/components/LicenseCard.tsx index 96f82b8b6..ef9b1f647 100644 --- a/apps/portal/src/app/features/licensing/components/LicenseCard.tsx +++ b/apps/portal/src/app/features/licensing/components/LicenseCard.tsx @@ -18,6 +18,7 @@ import { type IssuedLicense, } from '@ema-platform/api'; import { notify } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; /** * Renewal reuses the ordinary application wizard — a renewal is an @@ -56,14 +57,6 @@ function daysUntil(date: string): number { return Math.ceil(ms / 86_400_000); } -function formatDate(value: string): string { - return new Date(value).toLocaleDateString('en-GB', { - day: 'numeric', - month: 'short', - year: 'numeric', - }); -} - export function LicenseCard({ license, isDownloading, @@ -82,6 +75,7 @@ export function LicenseCard({ const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate); const expired = license.status === 'EXPIRED' || days < 0; const renewable = license.renewable ?? false; + const showDate = useDateDisplayer(); return ( @@ -111,7 +105,7 @@ export function LicenseCard({ {expired ? 'Expired on' : 'Valid until'} - {formatDate(license.expiryDate)} + {showDate(license.expiryDate)} diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx index e5794148c..fe69a7168 100644 --- a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage.tsx @@ -33,6 +33,7 @@ import { IconX, } from '@tabler/icons-react'; import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable, type AdvancedColumn } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; import { LicenseCatalogue } from '../components/LicenseCatalogue'; import { LicenseCard, useRenewLicense } from '../components/LicenseCard'; import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment'; @@ -90,6 +91,7 @@ function tabFromHash(hash: string): Tab { export function MyApplicationsPage() { const navigate = useNavigate(); const { t, i18n } = useTranslation(); + const showDate = useDateDisplayer(); const { data, isFetching, refetch } = useGetMyApplicationsQuery(); const { pay, isPaying } = useApplicationPayment(); const { data: capabilities } = useGetPaymentCapabilitiesQuery(); @@ -279,7 +281,7 @@ export function MyApplicationsPage() { cell: ({ row }) => ( {row.original.submittedAt - ? new Date(row.original.submittedAt).toLocaleDateString() + ? showDate(row.original.submittedAt) : t('applications.card.notFiled')} ), diff --git a/apps/portal/src/app/features/notifications/pages/NotificationsPage.tsx b/apps/portal/src/app/features/notifications/pages/NotificationsPage.tsx index b2b109de1..76801b71e 100644 --- a/apps/portal/src/app/features/notifications/pages/NotificationsPage.tsx +++ b/apps/portal/src/app/features/notifications/pages/NotificationsPage.tsx @@ -20,6 +20,7 @@ import { useGetUnseenNotificationsQuery, useMarkNotificationReadMutation, } from '@ema-platform/api'; +import { useDateDisplayer } from '@ema-platform/shared'; type Tab = 'all' | 'unseen' | 'seen'; @@ -37,6 +38,7 @@ const EMPTY_COPY: Record = { */ export function NotificationsPage() { const navigate = useNavigate(); + const showDate = useDateDisplayer(); const [tab, setTab] = useState('all'); const all = useGetNotificationsQuery(undefined, { skip: tab === 'unseen' }); const unseen = useGetUnseenNotificationsQuery(undefined, { skip: tab !== 'unseen' }); @@ -117,7 +119,7 @@ export function NotificationsPage() { {localized(n.content)} - {new Date(n.createdAt).toLocaleString()} + {showDate(n.createdAt)} {!n.isSeen && ( diff --git a/apps/portal/src/app/features/payments/pages/PaymentSuccessPage.tsx b/apps/portal/src/app/features/payments/pages/PaymentSuccessPage.tsx index 340188022..111bc0c73 100644 --- a/apps/portal/src/app/features/payments/pages/PaymentSuccessPage.tsx +++ b/apps/portal/src/app/features/payments/pages/PaymentSuccessPage.tsx @@ -12,11 +12,13 @@ import { } from '@mantine/core'; import { IconCircleCheck } from '@tabler/icons-react'; import { useGetApplicationPaymentQuery } from '@ema-platform/api'; +import { useDateDisplayer } from '@ema-platform/shared'; /** Confirmation that the licence fee has been received. */ export function PaymentSuccessPage() { const [params] = useSearchParams(); const navigate = useNavigate(); + const showDate = useDateDisplayer(); const applicationId = params.get('applicationId') ?? ''; const { data } = useGetApplicationPaymentQuery(applicationId, { skip: !applicationId, @@ -58,7 +60,7 @@ export function PaymentSuccessPage() { {data.paidAt && ( Paid - {new Date(data.paidAt).toLocaleString()} + {showDate(data.paidAt)} )} diff --git a/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx b/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx index 4d9a3f22d..36d1f8b7b 100644 --- a/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx +++ b/apps/portal/src/app/features/profile/components/OperationsFormContent.tsx @@ -20,6 +20,7 @@ import { useUpdateMyOperatorTypesMutation, } from '@ema-platform/api'; import { notify, ModalFooter } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; /** * The applicant's modes of operation — what they do, and therefore which @@ -62,6 +63,7 @@ export function OperationsFormContent({ [catalogue], ); + const showDate = useDateDisplayer(); const removed = declaredIds.filter((id) => !selected.includes(id)); const dirty = removed.length > 0 || selected.some((id) => !declaredIds.includes(id)); @@ -151,13 +153,7 @@ export function OperationsFormContent({ - {lastChanged - ? `Last changed ${new Date(lastChanged).toLocaleDateString('en-GB', { - day: 'numeric', - month: 'short', - year: 'numeric', - })}` - : 'Not set yet'} + {lastChanged ? `Last changed ${showDate(lastChanged)}` : 'Not set yet'} {dirty && ( diff --git a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage.tsx b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage.tsx index fa7071fb8..638abe10d 100644 --- a/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage.tsx +++ b/apps/portal/src/app/features/seafarer/pages/MySeaRecordsPage.tsx @@ -32,6 +32,7 @@ import { } from '@tabler/icons-react'; import { useState } from 'react'; import { AdvancedTable, AmharicDatePicker, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; import { extractErrorMessage, uploadDocument, @@ -156,6 +157,7 @@ const EMPTY_SEA_SERVICE = { }; function SeaServiceTab() { + const showDate = useDateDisplayer(); const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery(); const { data: seaTime } = useGetMySeaTimeQuery(); const [createRecord, { isLoading: creating }] = @@ -257,8 +259,16 @@ function SeaServiceTab() { ), }, { header: 'Rank', accessorKey: 'rank' }, - { header: 'From', accessorKey: 'engagementDate' }, - { header: 'To', accessorKey: 'dischargeDate' }, + { + header: 'From', + accessorKey: 'engagementDate', + cell: ({ row }) => showDate(row.original.engagementDate), + }, + { + header: 'To', + accessorKey: 'dischargeDate', + cell: ({ row }) => showDate(row.original.dischargeDate), + }, { header: 'Status', cell: ({ row }) => ( @@ -463,6 +473,7 @@ const EMPTY_MEDICAL = { }; function MedicalTab() { + const showDate = useDateDisplayer(); const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery(); const [createCertificate, { isLoading: creating }] = useCreateMedicalCertificateMutation(); @@ -557,12 +568,16 @@ function MedicalTab() { ), }, - { header: 'Issued', accessorKey: 'issueDate' }, + { + header: 'Issued', + accessorKey: 'issueDate', + cell: ({ row }) => showDate(row.original.issueDate), + }, { header: 'Expires', cell: ({ row }) => ( - {row.original.expiryDate} + {showDate(row.original.expiryDate)} {row.original.expiryDate < today && Expired} ), diff --git a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationStatusPage.tsx b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationStatusPage.tsx index 4e5841b21..037457bb1 100644 --- a/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationStatusPage.tsx +++ b/apps/portal/src/app/features/vessel-registration/pages/VesselRegistrationStatusPage.tsx @@ -24,6 +24,7 @@ import { IconShip, } from '@tabler/icons-react'; import { notify } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; import { MOCK_REGISTRATIONS, recordDownload, STATUS_COLOR } from '../mock'; // ponytail: placeholder PDF blob; wire real cert endpoint when backend lands. @@ -38,6 +39,7 @@ function downloadCertificate(filename: string) { export function VesselRegistrationStatusPage() { const navigate = useNavigate(); + const showDate = useDateDisplayer(); const { id } = useParams(); const [reg] = useState(() => MOCK_REGISTRATIONS.find((r) => r.id === id) ?? null); const [, forceUpdate] = useState(0); @@ -93,7 +95,7 @@ export function VesselRegistrationStatusPage() { > Registration {reg.renewal === 'Overdue' ? 'is overdue for renewal' : 'is due for renewal soon'} - {reg.expiryDate ? ` — expires ${reg.expiryDate}` : ''}. + {reg.expiryDate ? ` — expires ${showDate(reg.expiryDate)}` : ''}. )} @@ -132,7 +134,7 @@ export function VesselRegistrationStatusPage() {
{cert.name} - Certificate No. {cert.number} — Issued {cert.issueDate} + Certificate No. {cert.number} — Issued {showDate(cert.issueDate)} {cert.downloads > 0 && ( Downloaded {cert.downloads} time{cert.downloads === 1 ? '' : 's'} )} diff --git a/apps/portal/src/app/features/waiver/pages/WaiverPage.tsx b/apps/portal/src/app/features/waiver/pages/WaiverPage.tsx index a0afbb97b..a7cdf20d4 100644 --- a/apps/portal/src/app/features/waiver/pages/WaiverPage.tsx +++ b/apps/portal/src/app/features/waiver/pages/WaiverPage.tsx @@ -27,6 +27,7 @@ import { useGetMyLicensesQuery, } from '@ema-platform/api'; import { notify } from '@ema-platform/ui'; +import { useDateDisplayer } from '@ema-platform/shared'; const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER']; @@ -43,6 +44,7 @@ export function WaiverPage() { const { data: applications, isLoading } = useGetMyApplicationsQuery(); const { data: licenses } = useGetMyLicensesQuery(); const [getCertificateUrl] = useGetCertificateUrlMutation(); + const showDate = useDateDisplayer(); const waiverApplications = (applications?.items ?? []).filter((app) => WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''), @@ -183,7 +185,7 @@ export function WaiverPage() { {license.licenseType?.name?.en ?? '—'} - {license.issueDate?.slice(0, 10)} + {showDate(license.issueDate)} p.value === period)?.label ?? ''; + return `${String(hour).padStart(2, '0')}:${minutes} ${label}`; +} diff --git a/libs/shared/src/lib/date/use-date-displayer.ts b/libs/shared/src/lib/date/use-date-displayer.ts new file mode 100644 index 000000000..f68aabe92 --- /dev/null +++ b/libs/shared/src/lib/date/use-date-displayer.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { dateDisplayer } from './date-displayer'; + +/** + * The component-facing date formatter. + * + * A hook rather than a bare import so the calling component is subscribed to + * i18next: switching language re-renders it and the dates flip with everything + * else. `useTranslation()` with no instance argument resolves to the app's own + * (portal router.tsx, backoffice AppProviders.tsx), which is + * what makes this work across two separate i18n instances. + * + * The returned function is stable per language, so it is safe — and required — + * as a useMemo/useCallback dependency. + */ +export function useDateDisplayer(): ( + value: string | number | Date | null | undefined, +) => string { + const { i18n } = useTranslation(); + return useCallback((value) => dateDisplayer(value, i18n.language), [i18n.language]); +} diff --git a/libs/shared/vitest.config.ts b/libs/shared/vitest.config.ts new file mode 100644 index 000000000..3a1195e49 --- /dev/null +++ b/libs/shared/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../node_modules/.vite/libs/shared', + plugins: [nxViteTsPaths()], + test: { + watch: false, + globals: true, + environment: 'node', + reporters: ['default'], + }, +}); diff --git a/libs/ui/src/lib/input/AmharicDatePicker.tsx b/libs/ui/src/lib/input/AmharicDatePicker.tsx index 9423a10a4..78919ee2a 100644 --- a/libs/ui/src/lib/input/AmharicDatePicker.tsx +++ b/libs/ui/src/lib/input/AmharicDatePicker.tsx @@ -17,77 +17,20 @@ 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'; +import { + type EthPeriod, + ETH_PERIODS, + ethMonthName, + ethTimeLabel, + fromEthTime, + toAmharicDisplay, + toEthDateTime, + toEthTime, +} from '@ema-platform/shared'; -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'); - } -} - -export type EthPeriod = 'lelit' | 'tewat' | 'ken' | 'mata'; - -// Ethiopian day starts at 6am. Period is picked from the 24h hour; the -// displayed hour is the Western hour shifted 6, wrapped onto a 12-hour dial. -// Each period only spans 6 hours (not 12): ጠዋት/ማታ show 12,1..5, ቀን/ሌሊት show -// 6..11 — offering all 12 hours in every period would let a user pick e.g. -// "ጠዋት 7", which has no 06:00–11:59 preimage. -const ETH_PERIODS: { value: EthPeriod; label: string; hours: number[] }[] = [ - { value: 'tewat', label: 'ጠዋት', hours: [12, 1, 2, 3, 4, 5] }, // 06:00–11:59 - { value: 'ken', label: 'ቀን', hours: [6, 7, 8, 9, 10, 11] }, // 12:00–17:59 - { value: 'mata', label: 'ማታ', hours: [12, 1, 2, 3, 4, 5] }, // 18:00–23:59 - { value: 'lelit', label: 'ሌሊት', hours: [6, 7, 8, 9, 10, 11] }, // 00:00–05:59 -]; - -export function toEthTime(h24: number): { period: EthPeriod; hour: number } { - const period: EthPeriod = - h24 < 6 ? 'lelit' : h24 < 12 ? 'tewat' : h24 < 18 ? 'ken' : 'mata'; - return { period, hour: ((h24 + 6) % 12) || 12 }; -} - -export function fromEthTime(period: EthPeriod, hour: number): number { - // Inverse of the shift, then re-add the 12h that %12 discarded for the - // afternoon/night pair of periods. - const pm = period === 'ken' || period === 'mata'; - return ((hour + 6) % 12) + (pm ? 12 : 0); -} - -function ethTimeLabel(date: Date): string { - const { period, hour } = toEthTime(date.getHours()); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const label = ETH_PERIODS.find((p) => p.value === period)?.label ?? ''; - return `${hour}:${minutes} ${label}`; -} +export type { EthPeriod }; // react-day-picker calls these with the Gregorian Date it tracks internally; // override so the caption/dropdown show Amharic month names instead of the @@ -168,30 +111,6 @@ function mergeDateTime( 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);