Files
CRM_Montzar/apps/web/src/lib/jalali.ts
T

368 lines
10 KiB
TypeScript
Raw Normal View History

/** تبدیل و ابزار تقویم شمسی (الگوریتم استاندارد جلالی) */
export type JalaliDate = { jy: number; jm: number; jd: number };
const MONTH_NAMES = [
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
] as const;
const WEEKDAY_NAMES = [
'یکشنبه',
'دوشنبه',
'سه‌شنبه',
'چهارشنبه',
'پنجشنبه',
'جمعه',
'شنبه',
] as const;
function div(a: number, b: number) {
return Math.trunc(a / b);
}
export function isLeapJalaliYear(jy: number): boolean {
const breaks = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192,
2262, 2324, 2394, 2456, 3178,
];
const bl = breaks.length;
let jp = breaks[0];
let jump = 0;
for (let i = 1; i < bl; i += 1) {
const jm = breaks[i];
jump = jm - jp;
if (jy < jm) break;
jp = jm;
}
let n = jy - jp;
if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
let leap = mod(n + 1, 33) - 1;
if (leap === -1) leap = 32;
return leap === 0 || leap % 4 === 0;
}
function mod(a: number, b: number) {
return a - Math.trunc(a / b) * b;
}
export function jalaliMonthLength(jy: number, jm: number): number {
if (jm <= 6) return 31;
if (jm <= 11) return 30;
return isLeapJalaliYear(jy) ? 30 : 29;
}
export function toJalali(gy: number, gm: number, gd: number): JalaliDate {
const gdm = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let gy2 = gm > 2 ? gy + 1 : gy;
let days =
355666 +
365 * gy +
div(gy2 + 3, 4) -
div(gy2 + 99, 100) +
div(gy2 + 399, 400) +
gd +
gdm[gm - 1];
let jy = -1595 + 33 * div(days, 12053);
days %= 12053;
jy += 4 * div(days, 1461);
days %= 1461;
if (days > 365) {
jy += div(days - 1, 365);
days = (days - 1) % 365;
}
const jm = days < 186 ? 1 + div(days, 31) : 7 + div(days - 186, 30);
const jd = 1 + (days < 186 ? days % 31 : (days - 186) % 30);
return { jy, jm, jd };
}
export function toGregorian(jy: number, jm: number, jd: number): {
gy: number;
gm: number;
gd: number;
} {
const jy2 = jy + 1595;
let days =
-355668 +
365 * jy2 +
div(jy2, 33) * 8 +
div(mod(jy2, 33) + 3, 4) +
jd +
(jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186);
let gy = 400 * div(days, 146097);
days %= 146097;
if (days > 36524) {
gy += 100 * div(--days, 36524);
days %= 36524;
if (days >= 365) days += 1;
}
gy += 4 * div(days, 1461);
days %= 1461;
if (days > 365) {
gy += div(days - 1, 365);
days = (days - 1) % 365;
}
const gd = days + 1;
const salA = [
0,
31,
(gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0 ? 29 : 28,
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let gm = 0;
let dayAcc = gd;
for (gm = 1; gm <= 12 && dayAcc > salA[gm]; gm += 1) {
dayAcc -= salA[gm];
}
return { gy, gm, gd: dayAcc };
}
export function dateToJalali(d: Date): JalaliDate {
return toJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
}
export function jalaliToDate(jy: number, jm: number, jd: number): Date {
const { gy, gm, gd } = toGregorian(jy, jm, jd);
return new Date(gy, gm - 1, gd);
}
export function addJalaliMonths(jy: number, jm: number, delta: number): {
jy: number;
jm: number;
} {
const idx = jy * 12 + (jm - 1) + delta;
return {
jy: Math.floor(idx / 12),
jm: (idx % 12) + 1,
};
}
export function jalaliMonthName(jm: number): string {
return MONTH_NAMES[jm - 1] ?? '';
}
export function weekdayName(d: Date): string {
return WEEKDAY_NAMES[d.getDay()] ?? '';
}
/** مثل: پنجشنبه ۵ شهریور ۱۴۰۵ */
export function formatJalaliLong(d: Date): string {
const { jy, jm, jd } = dateToJalali(d);
return `${weekdayName(d)} ${toFaDigits(jd)} ${jalaliMonthName(jm)} ${toFaDigits(jy)}`;
}
/** مثل: دوشنبه ۲۶ مرداد */
export function formatJalaliShort(d: Date): string {
const { jm, jd } = dateToJalali(d);
return `${weekdayName(d)} ${toFaDigits(jd)} ${jalaliMonthName(jm)}`;
}
/** مثل: ۸ شهریور ۱۴۰۵ */
export function formatJalaliDay(d: Date): string {
const { jy, jm, jd } = dateToJalali(d);
return `${toFaDigits(jd)} ${jalaliMonthName(jm)} ${toFaDigits(jy)}`;
}
export function toFaDigits(value: number | string): string {
return String(value).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)]!);
}
export function startOfLocalDay(d = new Date()): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
export function formatClockFa(d: Date): string {
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${toFaDigits(hh)}:${toFaDigits(mm)}`;
}
/** مثل: ۲۴ مرداد ۱۱:۲۰ */
export function formatJalaliDayClock(d: Date): string {
const { jm, jd } = dateToJalali(d);
return `${toFaDigits(jd)} ${jalaliMonthName(jm)} ${formatClockFa(d)}`;
}
2026-10-13 18:04:53 +03:30
/** مثل: ۸ شهریور ۱۴۰۵، ۱۶:۳۰ */
export function formatJalaliDateTime(d: Date): string {
return `${formatJalaliDay(d)}، ${formatClockFa(d)}`;
}
export function calendarDaysBetween(from: Date, to: Date): number {
return Math.round(
(startOfLocalDay(to).getTime() - startOfLocalDay(from).getTime()) / 86_400_000,
);
}
/** آخرین ارتباط: امروز ۱۶:۳۰ / ۶ روز پیش */
export function formatRelativeContact(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
const days = calendarDaysBetween(d, new Date());
if (days <= 0) return `امروز ${formatClockFa(d)}`;
if (days === 1) return 'دیروز';
return `${toFaDigits(days)} روز پیش`;
}
/** فعالیت بعدی: امروز ۱۶:۳۰ / فردا ۱۰:۰۰ / ۲۴ مرداد ۱۱:۲۰ */
export function formatNextSlot(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
const days = calendarDaysBetween(new Date(), d);
if (days === 0) return `امروز ${formatClockFa(d)}`;
if (days === 1) return `فردا ${formatClockFa(d)}`;
if (days === -1) return `دیروز ${formatClockFa(d)}`;
return formatJalaliDayClock(d);
}
export function gregorianDayKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function parseLocalInput(value: string | null | undefined): Date | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?/.exec(value);
if (match) {
return new Date(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
match[4] ? Number(match[4]) : 0,
match[5] ? Number(match[5]) : 0,
);
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
export function formatLocalDateInput(d: Date): string {
return gregorianDayKey(d);
}
export function formatLocalDateTimeInput(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${gregorianDayKey(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** مثل: ۱۴۰۵/۰۶/۱۲ */
export function formatJalaliNumeric(d: Date): string {
const { jy, jm, jd } = dateToJalali(d);
return `${toFaDigits(jy)}/${toFaDigits(String(jm).padStart(2, '0'))}/${toFaDigits(String(jd).padStart(2, '0'))}`;
}
2026-10-13 18:04:53 +03:30
const GREG_MONTHS: Array<[string, number]> = [
['ژانویه', 1],
['january', 1],
['فوریه', 2],
['february', 2],
['مارس', 3],
['march', 3],
['آوریل', 4],
['april', 4],
['ژوئن', 6],
['june', 6],
['ژوئیه', 7],
['جولای', 7],
['july', 7],
['آگوست', 8],
['آگست', 8],
['اوت', 8],
['august', 8],
['سپتامبر', 9],
['september', 9],
['اکتبر', 10],
['october', 10],
['نوامبر', 11],
['november', 11],
['دسامبر', 12],
['december', 12],
['مه', 5],
['می', 5],
['may', 5],
];
function toAsciiDigits(value: string) {
return value
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
}
function gregMonthNumber(name: string): number | null {
const key = name.trim().toLowerCase();
return GREG_MONTHS.find(([label]) => label === key)?.[1] ?? null;
}
function inferGregorianYear(month: number, day: number, now = new Date()): number {
const gy = now.getFullYear();
const gm = now.getMonth() + 1;
const gd = now.getDate();
if (month > gm || (month === gm && day > gd)) return gy - 1;
return gy;
}
/** «21 سپتامبر» و تاریخ میلادی عددی را در متن نمایشی به شمسی تبدیل می‌کند. */
export function rewriteGregorianDates(text: string, now = new Date()): string {
const monthAlt = GREG_MONTHS.map(([name]) => name).join('|');
const dayMonth = new RegExp(
`(^|[^\\p{L}\\d])([0-9۰-۹]{1,2})\\s*(${monthAlt})(?![\\p{L}\\u200c])(?:\\s+([0-9۰-۹]{4}))?`,
'giu',
);
const monthDay = new RegExp(
`(^|[^\\p{L}\\d])(${monthAlt})(?![\\p{L}\\u200c])\\s+([0-9۰-۹]{1,2})(?:\\s*,?\\s*([0-9۰-۹]{4}))?`,
'giu',
);
const iso = /\b((?:19|20)\d{2})-(\d{2})-(\d{2})\b/g;
const stamp = (year: number, month: number, day: number) => {
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1700 || year > 2500) return null;
const j = toJalali(year, month, day);
return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`;
};
const fromParts = (dayRaw: string, monthName: string, yearRaw?: string) => {
const day = Number(toAsciiDigits(dayRaw));
const month = gregMonthNumber(monthName);
if (!month) return null;
const year = yearRaw ? Number(toAsciiDigits(yearRaw)) : inferGregorianYear(month, day, now);
return stamp(year, month, day);
};
let out = text.replace(dayMonth, (full, lead: string, day: string, month: string, year?: string) => {
const next = fromParts(day, month, year);
return next ? `${lead}${next}` : full;
});
out = out.replace(monthDay, (full, lead: string, month: string, day: string, year?: string) => {
const next = fromParts(day, month, year);
return next ? `${lead}${next}` : full;
});
out = out.replace(iso, (full, year: string, month: string, day: string) => {
return stamp(Number(year), Number(month), Number(day)) ?? full;
});
return out;
}