improvement: rtl direction, solar calendar and persian formatting added for persian users.
This commit is contained in:
45
frontend/src/lib/hooks/useAppFormatters.ts
Normal file
45
frontend/src/lib/hooks/useAppFormatters.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
import {
|
||||
APP_DATE,
|
||||
formatAppDate,
|
||||
formatAppDateTime,
|
||||
formatAppNumber,
|
||||
formatAppTableDate,
|
||||
formatAppTime,
|
||||
formatAppTimeRange,
|
||||
createAppDateFormatter,
|
||||
} from '@/lib/i18n/format';
|
||||
|
||||
export function useAppFormatters() {
|
||||
const locale = useLocale();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
locale,
|
||||
formatDate: (value: Parameters<typeof formatAppDate>[0], options?: Intl.DateTimeFormatOptions) =>
|
||||
formatAppDate(value, locale, options),
|
||||
formatTime: (value: Parameters<typeof formatAppTime>[0], options?: Intl.DateTimeFormatOptions) =>
|
||||
formatAppTime(value, locale, options),
|
||||
formatDateTime: (
|
||||
value: Parameters<typeof formatAppDateTime>[0],
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
) => formatAppDateTime(value, locale, options),
|
||||
formatTimeRange: (
|
||||
start: Parameters<typeof formatAppTimeRange>[0],
|
||||
end: Parameters<typeof formatAppTimeRange>[1],
|
||||
options?: Intl.DateTimeFormatOptions,
|
||||
) => formatAppTimeRange(start, end, locale, options),
|
||||
formatTableDate: (value: Parameters<typeof formatAppTableDate>[0]) =>
|
||||
formatAppTableDate(value, locale),
|
||||
formatNumber: (value: number, options?: Intl.NumberFormatOptions) =>
|
||||
formatAppNumber(value, locale, options),
|
||||
dateFormatter: (options: Intl.DateTimeFormatOptions = APP_DATE.chartDay) =>
|
||||
createAppDateFormatter(locale, options),
|
||||
presets: APP_DATE,
|
||||
}),
|
||||
[locale],
|
||||
);
|
||||
}
|
||||
43
frontend/src/lib/i18n/dateInputFormat.ts
Normal file
43
frontend/src/lib/i18n/dateInputFormat.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { toLatinDigits } from '@/lib/i18n/persianCalendar';
|
||||
|
||||
/** Force `YYYY-MM-DD` shape while typing (Latin digits). */
|
||||
export function maskGregorianDateTyping(raw: string): string {
|
||||
const digits = toLatinDigits(raw).replace(/\D/g, '').slice(0, 8);
|
||||
const segments: string[] = [];
|
||||
if (digits.length > 0) segments.push(digits.slice(0, Math.min(4, digits.length)));
|
||||
if (digits.length > 4) segments.push(digits.slice(4, Math.min(6, digits.length)));
|
||||
if (digits.length > 6) segments.push(digits.slice(6, 8));
|
||||
return segments.join('-');
|
||||
}
|
||||
|
||||
/** Parse typed Gregorian `YYYY-MM-DD` (slashes OK) → `YYYY-MM-DD` or null. */
|
||||
export function parseGregorianDateInputText(raw: string): string | null {
|
||||
const normalized = toLatinDigits(raw.trim()).replace(/\//g, '-');
|
||||
const match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(normalized);
|
||||
if (!match) return null;
|
||||
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (month < 1 || month > 12 || day < 1) return null;
|
||||
|
||||
const date = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gm = String(month).padStart(2, '0');
|
||||
const gd = String(day).padStart(2, '0');
|
||||
return `${year}-${gm}-${gd}`;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` → typed Gregorian field text. */
|
||||
export function formatIsoAsGregorianDateInput(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
return match ? iso : '';
|
||||
}
|
||||
157
frontend/src/lib/i18n/format.ts
Normal file
157
frontend/src/lib/i18n/format.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { isAppLocale, localeHtmlLang } from '@/i18n/routing';
|
||||
import { getLocalPersianParts } from '@/lib/i18n/persianCalendar';
|
||||
|
||||
export const FORMAT_EMPTY = '—';
|
||||
|
||||
/** BCP 47 tag for Intl APIs (`fa` → `fa-IR`, etc.). */
|
||||
export function intlLocale(locale: string): string {
|
||||
return isAppLocale(locale) ? localeHtmlLang(locale) : locale;
|
||||
}
|
||||
|
||||
export function usesPersianCalendar(locale: string): boolean {
|
||||
return locale === 'fa';
|
||||
}
|
||||
|
||||
function withPersianCalendar(
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions,
|
||||
): Intl.DateTimeFormatOptions {
|
||||
if (!usesPersianCalendar(locale)) {
|
||||
return options;
|
||||
}
|
||||
return { calendar: 'persian', numberingSystem: 'arabext', ...options };
|
||||
}
|
||||
|
||||
export function toValidDate(value: Date | string | number | null | undefined): Date | null {
|
||||
if (value == null) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export function createAppDateFormatter(
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = {},
|
||||
): Intl.DateTimeFormat {
|
||||
return new Intl.DateTimeFormat(intlLocale(locale), withPersianCalendar(locale, options));
|
||||
}
|
||||
|
||||
export const APP_DATE = {
|
||||
short: { year: 'numeric', month: 'short', day: 'numeric' } satisfies Intl.DateTimeFormatOptions,
|
||||
withWeekday: {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
} satisfies Intl.DateTimeFormatOptions,
|
||||
dayPicker: {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
} satisfies Intl.DateTimeFormatOptions,
|
||||
chartDay: { weekday: 'short', month: 'short', day: 'numeric' } satisfies Intl.DateTimeFormatOptions,
|
||||
history: {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
} satisfies Intl.DateTimeFormatOptions,
|
||||
activity: {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
} satisfies Intl.DateTimeFormatOptions,
|
||||
} as const;
|
||||
|
||||
export function formatAppDate(
|
||||
value: Date | string | number | null | undefined,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = APP_DATE.short,
|
||||
): string {
|
||||
const date = toValidDate(value);
|
||||
if (!date) return FORMAT_EMPTY;
|
||||
return createAppDateFormatter(locale, options).format(date);
|
||||
}
|
||||
|
||||
export function formatAppTime(
|
||||
value: Date | string | number | null | undefined,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' },
|
||||
): string {
|
||||
const date = toValidDate(value);
|
||||
if (!date) return FORMAT_EMPTY;
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
...options,
|
||||
...(usesPersianCalendar(locale) ? { numberingSystem: 'arabext' } : {}),
|
||||
};
|
||||
return new Intl.DateTimeFormat(intlLocale(locale), timeOptions).format(date);
|
||||
}
|
||||
|
||||
export function formatAppDateTime(
|
||||
value: Date | string | number | null | undefined,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' },
|
||||
): string {
|
||||
const date = toValidDate(value);
|
||||
if (!date) return FORMAT_EMPTY;
|
||||
return new Intl.DateTimeFormat(intlLocale(locale), withPersianCalendar(locale, options)).format(date);
|
||||
}
|
||||
|
||||
export function formatAppTimeRange(
|
||||
start: Date | string | number,
|
||||
end: Date | string | number,
|
||||
locale: string,
|
||||
options: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' },
|
||||
): string {
|
||||
return `${formatAppTime(start, locale, options)} – ${formatAppTime(end, locale, options)}`;
|
||||
}
|
||||
|
||||
/** Schedule axis labels (hour/minute from midnight). */
|
||||
export function formatAppMinuteOfDay(minute: number, locale: string): string {
|
||||
const hours = Math.floor(minute / 60);
|
||||
const minutes = minute % 60;
|
||||
const date = new Date(2000, 0, 1, hours, minutes, 0, 0);
|
||||
return formatAppTime(date, locale, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: !usesPersianCalendar(locale),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatAppTableDate(
|
||||
value: Date | string | number | null | undefined,
|
||||
locale: string,
|
||||
): string {
|
||||
return formatAppDate(value, locale, APP_DATE.short);
|
||||
}
|
||||
|
||||
export function formatAppNumber(
|
||||
value: number,
|
||||
locale: string,
|
||||
options?: Intl.NumberFormatOptions,
|
||||
): string {
|
||||
return new Intl.NumberFormat(intlLocale(locale), options).format(value);
|
||||
}
|
||||
|
||||
/** Calendar parts (year/day) — no thousands separators. */
|
||||
export function formatAppInteger(value: number, locale: string): string {
|
||||
return new Intl.NumberFormat(intlLocale(locale), {
|
||||
useGrouping: false,
|
||||
...(usesPersianCalendar(locale) ? { numberingSystem: 'arabext' } : {}),
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** ScheduleDayPicker / calendar field trigger label — year without grouping. */
|
||||
export function formatAppPickerDateLabel(date: Date, locale: string): string {
|
||||
const formatter = createAppDateFormatter(locale, APP_DATE.dayPicker);
|
||||
const yearNum = usesPersianCalendar(locale)
|
||||
? getLocalPersianParts(date).year
|
||||
: date.getFullYear();
|
||||
return formatter
|
||||
.formatToParts(date)
|
||||
.map((part) => (part.type === 'year' ? formatAppInteger(yearNum, locale) : part.value))
|
||||
.join('');
|
||||
}
|
||||
214
frontend/src/lib/i18n/persianCalendar.ts
Normal file
214
frontend/src/lib/i18n/persianCalendar.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Jalali (Persian) calendar — Gregorian `Date` values stay the app’s internal model.
|
||||
* Conversion logic ported from jalaali-js (MIT).
|
||||
*/
|
||||
|
||||
export type PersianDateParts = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
};
|
||||
|
||||
const BREAKS = [
|
||||
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262,
|
||||
2324, 2394, 2456, 3178,
|
||||
];
|
||||
|
||||
function div(a: number, b: number): number {
|
||||
return Math.trunc(a / b);
|
||||
}
|
||||
|
||||
function mod(a: number, b: number): number {
|
||||
return a - Math.trunc(a / b) * b;
|
||||
}
|
||||
|
||||
function g2d(gy: number, gm: number, gd: number): number {
|
||||
let d =
|
||||
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) +
|
||||
div(153 * mod(gm + 9, 12) + 2, 5) +
|
||||
gd -
|
||||
34840408;
|
||||
d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
|
||||
return d;
|
||||
}
|
||||
|
||||
function d2g(jdn: number): { gy: number; gm: number; gd: number } {
|
||||
let j = 4 * jdn + 139361631;
|
||||
j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
|
||||
const i = div(mod(j, 1461), 4) * 5 + 308;
|
||||
const gd = div(mod(i, 153), 5) + 1;
|
||||
const gm = mod(div(i, 153), 12) + 1;
|
||||
const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
|
||||
return { gy, gm, gd };
|
||||
}
|
||||
|
||||
function jalCal(jy: number, withoutLeap: boolean): { leap?: number; gy: number; march: number } {
|
||||
const bl = BREAKS.length;
|
||||
let gy = jy + 621;
|
||||
let leapJ = -14;
|
||||
let jp = BREAKS[0];
|
||||
let jump = 0;
|
||||
let leap = 0;
|
||||
let n = 0;
|
||||
|
||||
if (jy < jp || jy >= BREAKS[bl - 1]) {
|
||||
throw new Error(`Invalid Jalaali year ${jy}`);
|
||||
}
|
||||
|
||||
for (let i = 1; i < bl; i += 1) {
|
||||
const jm = BREAKS[i];
|
||||
jump = jm - jp;
|
||||
if (jy < jm) break;
|
||||
leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
|
||||
jp = jm;
|
||||
}
|
||||
n = jy - jp;
|
||||
leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
|
||||
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
|
||||
|
||||
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
|
||||
const march = 20 + leapJ - leapG;
|
||||
|
||||
if (withoutLeap) return { gy, march };
|
||||
|
||||
if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
|
||||
leap = mod(mod(n + 1, 33) - 1, 4);
|
||||
if (leap === -1) leap = 4;
|
||||
return { leap, gy, march };
|
||||
}
|
||||
|
||||
function j2d(jy: number, jm: number, jd: number): number {
|
||||
const r = jalCal(jy, true);
|
||||
return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
|
||||
}
|
||||
|
||||
function d2j(jdn: number): { jy: number; jm: number; jd: number } {
|
||||
const { gy } = d2g(jdn);
|
||||
let jy = gy - 621;
|
||||
const r = jalCal(jy, false);
|
||||
const jdn1f = g2d(gy, 3, r.march);
|
||||
let k = jdn - jdn1f;
|
||||
let jm: number;
|
||||
let jd: number;
|
||||
|
||||
if (k >= 0) {
|
||||
if (k <= 185) {
|
||||
jm = 1 + div(k, 31);
|
||||
jd = mod(k, 31) + 1;
|
||||
return { jy, jm, jd };
|
||||
}
|
||||
k -= 186;
|
||||
} else {
|
||||
jy -= 1;
|
||||
k += 179;
|
||||
if (r.leap === 1) k += 1;
|
||||
}
|
||||
jm = 7 + div(k, 30);
|
||||
jd = mod(k, 30) + 1;
|
||||
return { jy, jm, jd };
|
||||
}
|
||||
|
||||
export function gregorianToJalali(gy: number, gm: number, gd: number): [number, number, number] {
|
||||
const { jy, jm, jd } = d2j(g2d(gy, gm, gd));
|
||||
return [jy, jm, jd];
|
||||
}
|
||||
|
||||
export function jalaliToGregorian(jy: number, jm: number, jd: number): [number, number, number] {
|
||||
const { gy, gm, gd } = d2g(j2d(jy, jm, jd));
|
||||
return [gy, gm, gd];
|
||||
}
|
||||
|
||||
export function isJalaliLeapYear(jy: number): boolean {
|
||||
const r = jalCal(jy, false);
|
||||
return r.leap === 0;
|
||||
}
|
||||
|
||||
export function jalaliDaysInMonth(jy: number, jm: number): number {
|
||||
if (jm <= 6) return 31;
|
||||
if (jm <= 11) return 30;
|
||||
return isJalaliLeapYear(jy) ? 30 : 29;
|
||||
}
|
||||
|
||||
export function getLocalPersianParts(date: Date): PersianDateParts {
|
||||
const [year, month, day] = gregorianToJalali(
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
date.getDate(),
|
||||
);
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
export function persianPartsToLocalDate(jy: number, jm: number, jd: number): Date {
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||
return new Date(gy, gm - 1, gd, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
export function persianYearRange(anchorYear: number, past = 10, future = 2): number[] {
|
||||
const years: number[] = [];
|
||||
for (let y = anchorYear - past; y <= anchorYear + future; y += 1) {
|
||||
years.push(y);
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
const persianMonthFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
month: 'long',
|
||||
numberingSystem: 'arabext',
|
||||
});
|
||||
|
||||
/** Jalali month name (Farvardin, …) for picker labels. */
|
||||
export function formatPersianMonthLabel(jy: number, jm: number): string {
|
||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, 15);
|
||||
return persianMonthFormatter.format(new Date(gy, gm - 1, gd));
|
||||
}
|
||||
|
||||
const ARABEXT_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
|
||||
|
||||
export function toLatinDigits(value: string): string {
|
||||
return value.replace(/[۰-۹]/g, (ch) => {
|
||||
const index = ARABEXT_DIGITS.indexOf(ch as (typeof ARABEXT_DIGITS)[number]);
|
||||
return index >= 0 ? String(index) : ch;
|
||||
});
|
||||
}
|
||||
|
||||
export function toArabextDigits(value: string): string {
|
||||
return value.replace(/\d/g, (d) => ARABEXT_DIGITS[Number(d)] ?? d);
|
||||
}
|
||||
|
||||
/** Parse typed Jalali `YYYY/MM/DD` (Latin or Persian digits) → `YYYY-MM-DD` or null. */
|
||||
export function parsePersianDateInputText(raw: string): string | null {
|
||||
const normalized = toLatinDigits(raw.trim()).replace(/-/g, '/');
|
||||
const match = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/.exec(normalized);
|
||||
if (!match) return null;
|
||||
|
||||
const jy = Number(match[1]);
|
||||
const jm = Number(match[2]);
|
||||
const jd = Number(match[3]);
|
||||
if (jm < 1 || jm > 12 || jd < 1 || jd > jalaliDaysInMonth(jy, jm)) return null;
|
||||
|
||||
const date = persianPartsToLocalDate(jy, jm, jd);
|
||||
const gy = date.getFullYear();
|
||||
const gm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const gd = String(date.getDate()).padStart(2, '0');
|
||||
return `${gy}-${gm}-${gd}`;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` → typed Jalali field text e.g. ۱۴۰۴/۰۴/۲۲ */
|
||||
export function formatIsoAsPersianDateInput(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const [gy, gm, gd] = iso.split('-').map(Number);
|
||||
if (!gy || !gm || !gd) return '';
|
||||
const { year, month, day } = getLocalPersianParts(new Date(gy, gm - 1, gd, 0, 0, 0, 0));
|
||||
const pad2 = (n: number) => toArabextDigits(String(n).padStart(2, '0'));
|
||||
return `${toArabextDigits(String(year))}/${pad2(month)}/${pad2(day)}`;
|
||||
}
|
||||
|
||||
/** Force `YYYY/MM/DD` shape while typing (Persian digits in output). */
|
||||
export function maskJalaliDateTyping(raw: string): string {
|
||||
const digits = toLatinDigits(raw).replace(/\D/g, '').slice(0, 8);
|
||||
const segments: string[] = [];
|
||||
if (digits.length > 0) segments.push(digits.slice(0, Math.min(4, digits.length)));
|
||||
if (digits.length > 4) segments.push(digits.slice(4, Math.min(6, digits.length)));
|
||||
if (digits.length > 6) segments.push(digits.slice(6, 8));
|
||||
return toArabextDigits(segments.join('/'));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LabCaseActivityItem } from '@/types/lab-case-activity';
|
||||
import { APP_DATE, formatAppDateTime } from '@/lib/i18n/format';
|
||||
|
||||
type ActivityLabelTranslator = (
|
||||
key: string,
|
||||
@@ -11,12 +12,7 @@ export function formatLabCaseActivityLine(
|
||||
locale: string,
|
||||
): string {
|
||||
const actor = activity.actorName ?? t('activityUnknownActor');
|
||||
const date = new Date(activity.createdAt).toLocaleString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const date = formatAppDateTime(activity.createdAt, locale, APP_DATE.activity);
|
||||
|
||||
switch (activity.type) {
|
||||
case 'CASE_SENT':
|
||||
|
||||
Reference in New Issue
Block a user