improvement: rtl direction, solar calendar and persian formatting added for persian users.

This commit is contained in:
2026-07-14 01:27:11 +03:30
parent 27eae25f61
commit ec2b23f4b1
50 changed files with 1410 additions and 528 deletions

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

View 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 : '';
}

View 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('');
}

View File

@@ -0,0 +1,214 @@
/**
* Jalali (Persian) calendar — Gregorian `Date` values stay the apps 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('/'));
}

View File

@@ -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':