104 lines
3.3 KiB
TypeScript
104 lines
3.3 KiB
TypeScript
/* ============================================================================
|
||
LOCALE-AWARE FORMATTING
|
||
----------------------------------------------------------------------------
|
||
Thin `Intl` wrappers so a route never hard-codes a date pattern. Arabic uses
|
||
ar-LY with the `latn` numbering system: the approved draft copy writes
|
||
Arabic dates and figures in Western digits ("30 يونيو", "2025"), so Eastern
|
||
Arabic numerals would contradict the source content.
|
||
========================================================================= */
|
||
|
||
import { INTL_LOCALE, type Locale } from "./config";
|
||
|
||
const NUMBERING_SYSTEM = "latn";
|
||
|
||
/** The BCP-47 tag (with numbering system) handed to `Intl`. */
|
||
export function intlLocale(locale: Locale): string {
|
||
return `${INTL_LOCALE[locale]}-u-nu-${NUMBERING_SYSTEM}`;
|
||
}
|
||
|
||
function toDate(value: Date | string | number): Date | null {
|
||
const date = value instanceof Date ? value : new Date(value);
|
||
return Number.isNaN(date.getTime()) ? null : date;
|
||
}
|
||
|
||
export type DateStyle = "full" | "long" | "medium" | "short";
|
||
|
||
/** "12 March 2026" / "12 مارس 2026". Returns "" for an unparseable input. */
|
||
export function formatDate(
|
||
value: Date | string | number | null | undefined,
|
||
locale: Locale,
|
||
options: Intl.DateTimeFormatOptions = { dateStyle: "long" },
|
||
): string {
|
||
if (value === null || value === undefined) return "";
|
||
const date = toDate(value);
|
||
if (!date) return "";
|
||
|
||
return new Intl.DateTimeFormat(intlLocale(locale), options).format(date);
|
||
}
|
||
|
||
/** Date plus time of day, for an event card or an event detail page. */
|
||
export function formatDateTime(
|
||
value: Date | string | number | null | undefined,
|
||
locale: Locale,
|
||
options: Intl.DateTimeFormatOptions = {
|
||
dateStyle: "long",
|
||
timeStyle: "short",
|
||
},
|
||
): string {
|
||
return formatDate(value, locale, options);
|
||
}
|
||
|
||
/** "12–14 March 2026". Falls back to a single date when `end` is missing. */
|
||
export function formatDateRange(
|
||
start: Date | string | number,
|
||
end: Date | string | number | null | undefined,
|
||
locale: Locale,
|
||
options: Intl.DateTimeFormatOptions = { dateStyle: "long" },
|
||
): string {
|
||
const from = toDate(start);
|
||
if (!from) return "";
|
||
|
||
const to = end === null || end === undefined ? null : toDate(end);
|
||
if (!to || from.getTime() === to.getTime()) {
|
||
return formatDate(from, locale, options);
|
||
}
|
||
|
||
return new Intl.DateTimeFormat(intlLocale(locale), options).formatRange(
|
||
from,
|
||
to,
|
||
);
|
||
}
|
||
|
||
/** The machine-readable value for `<time dateTime>` - always ISO, never localised. */
|
||
export function toIsoDate(
|
||
value: Date | string | number | null | undefined,
|
||
): string | undefined {
|
||
if (value === null || value === undefined) return undefined;
|
||
return toDate(value)?.toISOString();
|
||
}
|
||
|
||
export function formatNumber(
|
||
value: number,
|
||
locale: Locale,
|
||
options?: Intl.NumberFormatOptions,
|
||
): string {
|
||
return new Intl.NumberFormat(intlLocale(locale), options).format(value);
|
||
}
|
||
|
||
/** "1.2 MB" - used by the resources list for a download's file size. */
|
||
export function formatFileSize(bytes: number, locale: Locale): string {
|
||
const units = ["B", "KB", "MB", "GB"] as const;
|
||
let size = Math.max(0, bytes);
|
||
let unit = 0;
|
||
|
||
while (size >= 1024 && unit < units.length - 1) {
|
||
size /= 1024;
|
||
unit += 1;
|
||
}
|
||
|
||
const formatted = formatNumber(size, locale, {
|
||
maximumFractionDigits: unit === 0 ? 0 : 1,
|
||
});
|
||
return `${formatted} ${units[unit]}`;
|
||
}
|