mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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:
@@ -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';
|
||||
|
||||
57
libs/shared/src/lib/date/date-displayer.ts
Normal file
57
libs/shared/src/lib/date/date-displayer.ts
Normal 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}`;
|
||||
}
|
||||
69
libs/shared/src/lib/date/ethiopic.ts
Normal file
69
libs/shared/src/lib/date/ethiopic.ts
Normal 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:00–11:59 preimage.
|
||||
export 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);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
22
libs/shared/src/lib/date/use-date-displayer.ts
Normal file
22
libs/shared/src/lib/date/use-date-displayer.ts
Normal 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]);
|
||||
}
|
||||
14
libs/shared/vitest.config.ts
Normal file
14
libs/shared/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user