feat: implement date display utility across various pages

- Added `useDateDisplayer` hook and `dateDisplayer` function to format dates consistently based on the user's language.
- Updated multiple components and pages in both backoffice and portal applications to utilize the new date display functionality, ensuring proper formatting for dates in lists, tables, and detail views.
- Introduced Ethiopian date formatting for Amharic language support.
- Refactored date handling in components such as ExamAppealsPage, ResultPage, SeafarerRegistryPage, and others to improve localization and user experience.
This commit is contained in:
estifanos
2026-08-12 10:22:39 +00:00
parent 33935419fb
commit 9897a9bf78
30 changed files with 286 additions and 168 deletions

View File

@@ -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<ExamIncidentStatus, string> = {
*/
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 }) {
)}
</Table.Td>
<Table.Td>
<Text fz="xs">{incident.occurredAt?.slice(0, 10)}</Text>
<Text fz="xs">{showDate(incident.occurredAt)}</Text>
</Table.Td>
<Table.Td>
<Badge

View File

@@ -2,6 +2,7 @@ import { Table, Badge, ActionIcon, Text } from '@mantine/core';
import { IconTrash } from '@tabler/icons-react';
import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../api/item-api';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const STATUS_COLORS: Record<Item['status'], string> = {
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() {
<Table.Td>
<Badge color={STATUS_COLORS[item.status]}>{item.status}</Badge>
</Table.Td>
<Table.Td>{new Date(item.createdAt).toLocaleDateString()}</Table.Td>
<Table.Td>{showDate(item.createdAt)}</Table.Td>
<Table.Td>
<ActionIcon
color="red"

View File

@@ -19,6 +19,7 @@ import {
} from '@mantine/core';
import { IconSearch, IconShieldCog } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
localized,
@@ -166,6 +167,7 @@ export function LicenseRegisterPage() {
const [target, setTarget] = useState<IssuedLicense | null>(null);
const items = data?.items ?? [];
const showDate = useDateDisplayer();
return (
<Container size="xl" py="md">
@@ -227,12 +229,12 @@ export function LicenseRegisterPage() {
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{license.issueDate?.slice(0, 10)}
{showDate(license.issueDate)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{license.expiryDate?.slice(0, 10)}
{showDate(license.expiryDate)}
</Text>
</Table.Td>
<Table.Td>

View File

@@ -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<EntryKind, typeof IconArrowRight> = {
* 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<ActivityEntry[]>(() => {
const merged: ActivityEntry[] = [];
@@ -159,12 +161,9 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
<Text size="xs" c="dimmed">
·
</Text>
<Tooltip
label={new Date(entry.at).toLocaleString(i18n.language)}
withArrow
>
<Tooltip label={showDate(entry.at)} withArrow>
<Text size="xs" c="dimmed">
{new Date(entry.at).toLocaleDateString(i18n.language)}
{showDate(entry.at.slice(0, 10))}
</Text>
</Tooltip>
</Group>

View File

@@ -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 },

View File

@@ -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 }) => (
<Text size="sm" c="dimmed">
{row.original.submittedAt
? new Date(row.original.submittedAt).toLocaleDateString(
i18n.language,
)
: "—"}
{dateDisplayer(row.original.submittedAt, i18n.language)}
</Text>
),
},
{
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.
<Tooltip label={sla.tooltip} withArrow>

View File

@@ -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() {
<SummaryRow label={t('review.kind', 'Kind')} value={app.kind} />
<SummaryRow
label={t('review.submitted', 'Submitted')}
value={
app.submittedAt
? new Date(app.submittedAt).toLocaleDateString(i18n.language)
: undefined
}
value={showDate(app.submittedAt)}
/>
<SummaryRow label={t('review.slaLabel', 'SLA')} value={sla.label} />
</Stack>
@@ -604,7 +602,7 @@ export function LicenseReviewPage() {
}
>
<Text size="xs" c="dimmed">
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
{showDate(entry.createdAt)}
</Text>
</Timeline.Item>
))}
@@ -848,7 +846,7 @@ export function LicenseReviewPage() {
<div>
<Text size="sm">
{inspection.scheduledDate
? new Date(inspection.scheduledDate).toLocaleString(i18n.language)
? showDate(inspection.scheduledDate)
: t('review.unscheduled', 'Not scheduled')}
</Text>
{inspection.findings && (

View File

@@ -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;

View File

@@ -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 }) => (
<Text size="sm">
{row.original.issueDate} {row.original.expiryDate}
{showDate(row.original.issueDate)} {showDate(row.original.expiryDate)}
</Text>
),
},
@@ -367,7 +370,7 @@ export function MedicalVerificationPage() {
),
},
],
[rulingMedical, rule, verifyMedical],
[rulingMedical, rule, verifyMedical, showDate],
);
const seaServiceColumns: AdvancedColumn<SeaServiceRecord>[] = useMemo(
@@ -415,7 +418,7 @@ export function MedicalVerificationPage() {
label: 'Period',
cell: ({ row }) => (
<Text size="sm">
{row.original.engagementDate} {row.original.dischargeDate}
{showDate(row.original.engagementDate)} {showDate(row.original.dischargeDate)}
</Text>
),
},
@@ -471,7 +474,7 @@ export function MedicalVerificationPage() {
),
},
],
[rulingSeaService, rule, verifySeaService],
[rulingSeaService, rule, verifySeaService, showDate],
);
return (

View File

@@ -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() {
</Text>
</Table.Td>
<Table.Td>
<Text fz="xs">{appeal.createdAt?.slice(0, 10)}</Text>
<Text fz="xs">{showDate(appeal.createdAt)}</Text>
</Table.Td>
<Table.Td>
<Button

View File

@@ -38,6 +38,7 @@ import {
IconSend,
} from '@tabler/icons-react';
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import type { BilingualValue } from '@ema-platform/ui';
import { extractErrorMessage } from '@ema-platform/api';
import {
@@ -116,6 +117,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
export function ResultPage() {
const { t, i18n } = useTranslation();
const showDate = useDateDisplayer();
const locale = i18n.language as 'en' | 'am';
const { handleError } = useErrorHandler();
const { data: examRes } = useGetExamsQuery();
@@ -344,7 +346,7 @@ export function ResultPage() {
},
{
header: t('result.columns.date'),
cell: ({ row }) => <Text fz="sm">{new Date(row.original.createdAt).toLocaleDateString()}</Text>,
cell: ({ row }) => <Text fz="sm">{showDate(row.original.createdAt)}</Text>,
},
{
header: '',
@@ -481,7 +483,7 @@ export function ResultPage() {
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<InfoRow label={t('result.detail.fullName')} value={detailResult.profile ? `${detailResult.profile.firstName} ${detailResult.profile.middleName ?? ''} ${detailResult.profile.lastName}` : detailResult.seafarerId} />
<InfoRow label={t('result.detail.gender')} value={detailResult.profile?.gender ?? '—'} />
<InfoRow label={t('result.detail.dateOfBirth')} value={detailResult.profile?.dob ? new Date(detailResult.profile.dob).toLocaleDateString() : '—'} />
<InfoRow label={t('result.detail.dateOfBirth')} value={showDate(detailResult.profile?.dob)} />
<InfoRow label={t('result.detail.maritalStatus')} value={detailResult.profile?.maritalStatus ?? '—'} />
</SimpleGrid>
</Paper>
@@ -499,7 +501,7 @@ export function ResultPage() {
<InfoRow label={t('result.detail.examTitle')} value={detailResult.exam.title?.[locale] ?? '—'} />
<InfoRow label={t('result.detail.type')} value={detailResult.exam.type ?? '—'} />
<InfoRow label={t('result.detail.venue')} value={detailResult.exam.venue ?? '—'} />
<InfoRow label={t('result.detail.date')} value={detailResult.exam.date ? new Date(detailResult.exam.date).toLocaleDateString() : '—'} />
<InfoRow label={t('result.detail.date')} value={showDate(detailResult.exam.date)} />
<InfoRow label={t('result.detail.passMark')} value={String(detailResult.exam.cuttingPoint ?? 0)} />
</SimpleGrid>
</Paper>

View File

@@ -25,6 +25,7 @@ import {
IconStethoscope,
} from '@tabler/icons-react';
import { AdvancedTable, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
useApiQuery,
@@ -81,6 +82,7 @@ function SeafarerDetailDrawer({
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
const { data: medical, isLoading: loadingMedical } =
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
const showDate = useDateDisplayer();
return (
<Drawer
@@ -177,7 +179,7 @@ function SeafarerDetailDrawer({
</Table.Td>
<Table.Td>{record.rank}</Table.Td>
<Table.Td>
{record.engagementDate} {record.dischargeDate}
{showDate(record.engagementDate)} {showDate(record.dischargeDate)}
</Table.Td>
<Table.Td>
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
@@ -213,7 +215,7 @@ function SeafarerDetailDrawer({
<Table.Tr key={certificate.id}>
<Table.Td>{certificate.issuerName}</Table.Td>
<Table.Td>
{certificate.issueDate} {certificate.expiryDate}
{showDate(certificate.issueDate)} {showDate(certificate.expiryDate)}
</Table.Td>
<Table.Td>{certificate.fitnessStatus}</Table.Td>
<Table.Td>

View File

@@ -27,6 +27,7 @@ import {
IconShieldCog,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
extractErrorMessage,
useGetVesselIncidentsQuery,
@@ -56,6 +57,7 @@ function VesselDetailDrawer({
}) {
const { data: incidents, isLoading: loadingIncidents } =
useGetVesselIncidentsQuery(vessel?.id ?? '', { skip: !vessel });
const showDate = useDateDisplayer();
const particulars: [string, string | number | null][] = vessel
? [
@@ -75,7 +77,7 @@ function VesselDetailDrawer({
['Engines', vessel.numberOfEngines],
['Hull material', vessel.hullMaterial],
['Owner', vessel.ownerName],
['Registered', vessel.registeredAt?.slice(0, 10) ?? null],
['Registered', vessel.registeredAt ? showDate(vessel.registeredAt) : null],
]
: [];
@@ -127,7 +129,7 @@ function VesselDetailDrawer({
<Card key={incident.id} withBorder radius="md" p="sm">
<Group justify="space-between">
<Text size="sm" fw={600}>
{incident.occurredAt}
{showDate(incident.occurredAt)}
{incident.location ? `${incident.location}` : ''}
</Text>
<Badge size="sm" variant="light">
@@ -240,6 +242,7 @@ export function VesselRegistrationQueuePage() {
const [statusTarget, setStatusTarget] = useState<Vessel | null>(null);
const items = data?.items ?? [];
const showDate = useDateDisplayer();
return (
<Container size="xl" py="md">
@@ -319,7 +322,7 @@ export function VesselRegistrationQueuePage() {
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{vessel.registeredAt?.slice(0, 10)}
{showDate(vessel.registeredAt)}
</Text>
</Table.Td>
<Table.Td>

View File

@@ -33,6 +33,7 @@ import {
} from '@ema-platform/api';
import { useCurrentProfile } from '@ema-platform/auth';
import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const CERTIFICATE_TYPE_KEYS = [
'CERTIFICATE_OF_COMPETENCY',
@@ -72,6 +73,7 @@ export function CertificatesPage() {
useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const showDate = useDateDisplayer();
const registered =
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
@@ -244,8 +246,8 @@ export function CertificatesPage() {
</Text>
</Table.Td>
<Table.Td>{license.licenseType?.name?.en}</Table.Td>
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
<Table.Td>{license.expiryDate?.slice(0, 10)}</Table.Td>
<Table.Td>{showDate(license.issueDate)}</Table.Td>
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
<Table.Td>
<Badge
size="sm"

View File

@@ -32,6 +32,7 @@ import {
} from '@ema-platform/api';
import { useCurrentProfile } from '@ema-platform/auth';
import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
@@ -66,6 +67,7 @@ export function EndorsementPage() {
const { data: applications, isLoading: loadingApplications } =
useGetMyApplicationsQuery();
const { data: licenses } = useGetMyLicensesQuery();
const showDate = useDateDisplayer();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const registered =
@@ -217,8 +219,8 @@ export function EndorsementPage() {
</Text>
</Table.Td>
<Table.Td>{localized(license.licenseType?.name)}</Table.Td>
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
<Table.Td>{license.expiryDate?.slice(0, 10)}</Table.Td>
<Table.Td>{showDate(license.issueDate)}</Table.Td>
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
<Table.Td>
<Badge
size="sm"

View File

@@ -14,6 +14,7 @@ import {
} from '@mantine/core';
import { IconClipboardList, IconFileText, IconGavel } from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import {
useApiQuery,
useApiMutation,
@@ -81,6 +82,7 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
* when a mark looks wrong.
*/
export function ExamsPage() {
const showDate = useDateDisplayer();
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
const [appealReason, setAppealReason] = useState('');
@@ -198,7 +200,7 @@ export function ExamsPage() {
<Text fw={600}>{exam.title?.en}</Text>
<Text size="xs" c="dimmed">
{exam.certification?.name?.en ?? ''} ·{' '}
{exam.date?.slice(0, 10)}
{showDate(exam.date)}
{exam.venue ? ` · ${exam.venue}` : ''}
</Text>
</div>
@@ -253,7 +255,7 @@ export function ExamsPage() {
</Text>
</Table.Td>
<Table.Td>{registration.exam?.title?.en ?? '—'}</Table.Td>
<Table.Td>{registration.exam?.date?.slice(0, 10)}</Table.Td>
<Table.Td>{showDate(registration.exam?.date)}</Table.Td>
<Table.Td>{registration.exam?.venue ?? '—'}</Table.Td>
<Table.Td>
<Badge
@@ -325,7 +327,7 @@ export function ExamsPage() {
return (
<Table.Tr key={result.id}>
<Table.Td>{result.exam?.title?.en ?? '—'}</Table.Td>
<Table.Td>{result.publishedAt?.slice(0, 10) ?? '—'}</Table.Td>
<Table.Td>{showDate(result.publishedAt)}</Table.Td>
<Table.Td>
<Text fw={600} size="sm">
{result.totalScore}

View File

@@ -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 (
<Card withBorder radius="md" padding="md" className="ema-hover-lift">
@@ -111,7 +105,7 @@ export function LicenseCard({
{expired ? 'Expired on' : 'Valid until'}
</Text>
<Text size="sm" fw={500}>
{formatDate(license.expiryDate)}
{showDate(license.expiryDate)}
</Text>
</Box>
<Tooltip label="Download certificate">

View File

@@ -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 }) => (
<Text size="sm" c="dimmed">
{row.original.submittedAt
? new Date(row.original.submittedAt).toLocaleDateString()
? showDate(row.original.submittedAt)
: t('applications.card.notFiled')}
</Text>
),

View File

@@ -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<Tab, string> = {
*/
export function NotificationsPage() {
const navigate = useNavigate();
const showDate = useDateDisplayer();
const [tab, setTab] = useState<Tab>('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)}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{new Date(n.createdAt).toLocaleString()}
{showDate(n.createdAt)}
</Text>
</div>
{!n.isSeen && (

View File

@@ -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 && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Paid</Text>
<Text size="sm">{new Date(data.paidAt).toLocaleString()}</Text>
<Text size="sm">{showDate(data.paidAt)}</Text>
</Group>
)}
</Stack>

View File

@@ -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({
<Group justify="space-between">
<Text size="xs" c="dimmed">
{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'}
</Text>
<Group gap="sm">
{dirty && (

View File

@@ -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 }) => (
<Group gap={6} wrap="nowrap">
{row.original.expiryDate}
{showDate(row.original.expiryDate)}
{row.original.expiryDate < today && <Badge color="red">Expired</Badge>}
</Group>
),

View File

@@ -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() {
>
<Text fz="sm">
Registration {reg.renewal === 'Overdue' ? 'is overdue for renewal' : 'is due for renewal soon'}
{reg.expiryDate ? ` — expires ${reg.expiryDate}` : ''}.
{reg.expiryDate ? ` — expires ${showDate(reg.expiryDate)}` : ''}.
</Text>
</Alert>
)}
@@ -132,7 +134,7 @@ export function VesselRegistrationStatusPage() {
<Group justify="space-between" wrap="wrap" gap="sm">
<div>
<Text fw={600} fz="sm">{cert.name}</Text>
<Text fz="xs" c="dimmed">Certificate No. {cert.number} Issued {cert.issueDate}</Text>
<Text fz="xs" c="dimmed">Certificate No. {cert.number} Issued {showDate(cert.issueDate)}</Text>
{cert.downloads > 0 && (
<Text fz="xs" c="dimmed">Downloaded {cert.downloads} time{cert.downloads === 1 ? '' : 's'}</Text>
)}

View File

@@ -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() {
</Text>
</Table.Td>
<Table.Td>{license.licenseType?.name?.en ?? '—'}</Table.Td>
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
<Table.Td>{showDate(license.issueDate)}</Table.Td>
<Table.Td>
<Badge
size="sm"

View File

@@ -1 +1,4 @@
export * from './lib/theme/ema-theme';
export * from './lib/date/date-displayer';
export * from './lib/date/use-date-displayer';
export * from './lib/date/ethiopic';

View File

@@ -0,0 +1,57 @@
import { ethTimeLabel, toAmharicDisplay } from './ethiopic';
/** Wire values carrying no time of day, e.g. a date column serialised as `2026-08-10`. */
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
/** Midnight UTC — how backends commonly serialise a plain date column. */
const UTC_MIDNIGHT = /T00:00:00(\.0+)?Z$/;
/**
* The one way a date is shown to a user.
*
* Timestamps render in the viewer's local timezone — `2026-08-10T06:57:34.507Z`
* reads as `Aug 10, 2026 9:57am` in Addis. Values with no real time of day
* render as a bare date: a birth date or a licence expiry stamped `12:00am`
* reads as precision the data does not have. Those are also formatted in UTC,
* because parsing `2026-08-10` yields UTC midnight, and converting that to a
* behind-UTC local timezone would show the previous day.
*
* `language` is a parameter rather than read from i18next because each app runs
* a DEDICATED i18n instance, not the global singleton (see `app/i18n/config.ts`)
* — the same reason `localized()` in licensing.helpers.ts takes one. Components
* should not call this directly; use `useDateDisplayer()` so the text actually
* re-renders when the language changes.
*/
export function dateDisplayer(
value: string | number | Date | null | undefined,
language = 'en',
): string {
if (value === null || value === undefined || value === '') return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
const dateOnly =
typeof value === 'string' && (DATE_ONLY.test(value) || UTC_MIDNIGHT.test(value));
if (language.startsWith('am')) {
const day = toAmharicDisplay(date); // ሐምሌ 22/2018
return dateOnly ? day : `${day} - ${ethTimeLabel(date)}`;
}
const day = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
...(dateOnly ? { timeZone: 'UTC' } : {}),
});
if (dateOnly) return day;
// Intl gives "9:57 AM"; the house format is "9:57am".
const time = date
.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
.replace(' ', '')
.toLowerCase();
return `${day} ${time}`;
}

View File

@@ -0,0 +1,69 @@
import { EthDateTime } from 'ethiopian-calendar-date-converter';
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.
export function toEthDateTime(date: Date): EthDateTime {
const utcNoon = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12),
);
return EthDateTime.fromEuropeanDate(utcNoon);
}
export function ethMonthName(date: Date): string {
try {
return EC_MONTHS_AM[toEthDateTime(date).month - 1] ?? '';
} catch {
return '';
}
}
export 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:0011:59 preimage.
export const ETH_PERIODS: { value: EthPeriod; label: string; hours: number[] }[] = [
{ value: 'tewat', label: 'ጠዋት', hours: [12, 1, 2, 3, 4, 5] }, // 06:0011:59
{ value: 'ken', label: 'ቀን', hours: [6, 7, 8, 9, 10, 11] }, // 12:0017:59
{ value: 'mata', label: 'ማታ', hours: [12, 1, 2, 3, 4, 5] }, // 18:0023:59
{ value: 'lelit', label: 'ሌሊት', hours: [6, 7, 8, 9, 10, 11] }, // 00:0005: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);
}
export 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 `${String(hour).padStart(2, '0')}:${minutes} ${label}`;
}

View File

@@ -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
* <I18nextProvider> (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]);
}

View File

@@ -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'],
},
});

View File

@@ -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:0011:59 preimage.
const ETH_PERIODS: { value: EthPeriod; label: string; hours: number[] }[] = [
{ value: 'tewat', label: 'ጠዋት', hours: [12, 1, 2, 3, 4, 5] }, // 06:0011:59
{ value: 'ken', label: 'ቀን', hours: [6, 7, 8, 9, 10, 11] }, // 12:0017:59
{ value: 'mata', label: 'ማታ', hours: [12, 1, 2, 3, 4, 5] }, // 18:0023:59
{ value: 'lelit', label: 'ሌሊት', hours: [6, 7, 8, 9, 10, 11] }, // 00:0005: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);