Files
CRM_Montzar/apps/web/src/lib/jalali.ts
T
MahdiandCursor cf326d1ed9 fix: keep birthdays and show Shamsi dates
Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 18:04:53 +03:30

368 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** تبدیل و ابزار تقویم شمسی (الگوریتم استاندارد جلالی) */
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)}`;
}
/** مثل: ۸ شهریور ۱۴۰۵، ۱۶:۳۰ */
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'))}`;
}
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;
}