From 80ffa37edd8e554e174231502e2fa63098da8dd6 Mon Sep 17 00:00:00 2001 From: Amin Mousavi Date: Thu, 20 Aug 2026 17:00:41 +0330 Subject: [PATCH] feat(backend): port Jalali calendar arithmetic with tests Voice extraction resolves spoken Jalali dates into ISO dates server-side, so the backend needs the conversion the frontend already had. The resolvers live here rather than in the frontend precisely because this half of the repo has a test runner. Ported from frontend/src/lib/i18n/persianCalendar.ts and verified faithful by differential test: every day from 1900-2100 (73,414 days), zero mismatches on conversion, leap years and month lengths. Two deliberate divergences from the original: - jalaliToIsoDate() returns null instead of throwing. It is fed model-supplied values, which may be nonsense, and an invalid date must degrade to "unresolved" rather than a 500. The year guard runs before jalaliDaysInMonth so the throwing jalCal is unreachable from it. - toLatinDigits() also handles the Arabic-Indic block (U+0660-U+0669), not just Persian (U+06F0-U+06F9). ASR output can carry either, sometimes mixed with ASCII in one transcript; the frontend version only parses keystrokes. Co-Authored-By: Claude Opus 5 (1M context) --- backend/src/common/jalali.spec.ts | 99 +++++++++++++++ backend/src/common/jalali.ts | 192 ++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 backend/src/common/jalali.spec.ts create mode 100644 backend/src/common/jalali.ts diff --git a/backend/src/common/jalali.spec.ts b/backend/src/common/jalali.spec.ts new file mode 100644 index 0000000..8895dc6 --- /dev/null +++ b/backend/src/common/jalali.spec.ts @@ -0,0 +1,99 @@ +import { + gregorianToJalali, + isJalaliLeapYear, + isValidJalaliDate, + jalaliDaysInMonth, + jalaliToGregorian, + jalaliToIsoDate, + toLatinDigits, +} from './jalali'; + +describe('jalali calendar', () => { + // Anchors verified against frontend/src/lib/i18n/persianCalendar.ts, the source this + // was ported from, over every day between 1900 and 2100. + it('converts known Jalali dates to ISO', () => { + expect(jalaliToIsoDate(1404, 1, 1)).toBe('2025-03-21'); // Nowruz 1404 + expect(jalaliToIsoDate(1404, 7, 25)).toBe('2025-10-17'); + expect(jalaliToIsoDate(1404, 6, 31)).toBe('2025-09-22'); // 31-day first half + expect(jalaliToIsoDate(1405, 1, 1)).toBe('2026-03-21'); + }); + + it('round-trips Gregorian → Jalali → Gregorian', () => { + for (const [gy, gm, gd] of [ + [2025, 3, 21], + [2025, 10, 17], + [2026, 1, 1], + [2024, 2, 29], // Gregorian leap day + [1999, 12, 31], + ] as const) { + const [jy, jm, jd] = gregorianToJalali(gy, gm, gd); + expect(jalaliToGregorian(jy, jm, jd)).toEqual([gy, gm, gd]); + } + }); + + describe('leap years', () => { + it('gives Esfand 30 days in a leap year and 29 otherwise', () => { + expect(isJalaliLeapYear(1403)).toBe(true); + expect(jalaliDaysInMonth(1403, 12)).toBe(30); + expect(jalaliToIsoDate(1403, 12, 30)).toBe('2025-03-20'); + + expect(isJalaliLeapYear(1404)).toBe(false); + expect(jalaliDaysInMonth(1404, 12)).toBe(29); + expect(jalaliToIsoDate(1404, 12, 29)).toBe('2026-03-20'); + }); + + it('rejects Esfand 30 in a non-leap year', () => { + expect(isValidJalaliDate(1404, 12, 30)).toBe(false); + expect(jalaliToIsoDate(1404, 12, 30)).toBeNull(); + }); + }); + + describe('month lengths', () => { + it('is 31 days for months 1-6 and 30 for 7-11', () => { + for (let m = 1; m <= 6; m += 1) + expect(jalaliDaysInMonth(1404, m)).toBe(31); + for (let m = 7; m <= 11; m += 1) + expect(jalaliDaysInMonth(1404, m)).toBe(30); + }); + }); + + describe('invalid input degrades to null rather than throwing', () => { + // The resolver feeds this model-supplied values, which may be nonsense. + it.each([ + ['month 13', 1404, 13, 1], + ['month 0', 1404, 0, 1], + ['day 0', 1404, 1, 0], + ['day 32 in a 31-day month', 1404, 1, 32], + ['day 31 in a 30-day month', 1404, 7, 31], + ['year beyond the conversion table', 9999, 1, 1], + ['non-integer day', 1404, 1, 1.5], + ])('returns null for %s', (_label, jy, jm, jd) => { + expect(jalaliToIsoDate(jy, jm, jd)).toBeNull(); + }); + }); + + describe('toLatinDigits', () => { + it('normalises Persian digits and leaves everything else alone', () => { + expect( + toLatinDigits('\u06F1\u06F4\u06F0\u06F4/\u06F0\u06F7/\u06F2\u06F5'), + ).toBe('1404/07/25'); + expect(toLatinDigits('1404/07/25')).toBe('1404/07/25'); + expect(toLatinDigits('\u062F\u0646\u062F\u0627\u0646 \u06F1\u06F4')).toBe( + '\u062F\u0646\u062F\u0627\u0646 14', + ); + }); + + it('also normalises the Arabic-Indic block, which ASR output can carry', () => { + // U+0660..U+0669, distinct code points from the Persian U+06F0..U+06F9 block. + expect( + toLatinDigits('\u0661\u0664\u0660\u0664/\u0660\u0667/\u0662\u0665'), + ).toBe('1404/07/25'); + }); + + it('normalises a transcript that mixes both blocks with ASCII', () => { + expect(toLatinDigits('\u06F1\u06F4 and \u0661\u0665 and 16')).toBe( + '14 and 15 and 16', + ); + }); + }); +}); diff --git a/backend/src/common/jalali.ts b/backend/src/common/jalali.ts new file mode 100644 index 0000000..0c72592 --- /dev/null +++ b/backend/src/common/jalali.ts @@ -0,0 +1,192 @@ +/** + * Jalali (Persian) calendar arithmetic. + * + * Ported from `frontend/src/lib/i18n/persianCalendar.ts` (itself from jalaali-js, MIT). + * The backend needs this because voice extraction resolves spoken Jalali dates into ISO + * dates server-side, where the resolvers are unit-tested — the frontend has no test + * runner. Keep the two copies in step; the underlying calendar does not change. + */ + +const BREAKS = [ + -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, + 2262, 2324, 2394, 2456, 3178, +]; + +/** Inclusive lower / exclusive upper Jalali year bounds the conversion table covers. */ +export const MIN_JALALI_YEAR = BREAKS[0]; +export const MAX_JALALI_YEAR = BREAKS[BREAKS.length - 1]; + +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; + const 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; +} + +/** + * Persian (Extended Arabic-Indic, U+06F0–U+06F9) zero, and Arabic-Indic (U+0660–U+0669) + * zero. ASR output can carry either block, sometimes mixed with ASCII in one transcript. + */ +const PERSIAN_ZERO = 0x06f0; +const ARABIC_INDIC_ZERO = 0x0660; + +/** + * Normalise Persian and Arabic-Indic digits to ASCII. Non-digits pass through. + * + * Deliberately wider than the frontend original, which only handles the Persian block: + * this parses model/ASR output rather than keystrokes, so both blocks must be accepted + * or a spoken date silently degrades to "unresolved". + */ +export function toLatinDigits(value: string): string { + return value.replace(/[\u06F0-\u06F9\u0660-\u0669]/g, (ch) => { + const code = ch.charCodeAt(0); + const base = code >= PERSIAN_ZERO ? PERSIAN_ZERO : ARABIC_INDIC_ZERO; + return String(code - base); + }); +} + +/** True when the triple is a real Jalali date inside the supported year range. */ +export function isValidJalaliDate(jy: number, jm: number, jd: number): boolean { + if (!Number.isInteger(jy) || !Number.isInteger(jm) || !Number.isInteger(jd)) { + return false; + } + if (jy < MIN_JALALI_YEAR || jy >= MAX_JALALI_YEAR) return false; + if (jm < 1 || jm > 12) return false; + if (jd < 1) return false; + return jd <= jalaliDaysInMonth(jy, jm); +} + +/** + * Jalali triple → `YYYY-MM-DD`, or null when the date is not real. + * + * Returns null rather than throwing: callers resolve model-supplied values, which may be + * nonsense, and an invalid date must degrade to "unresolved" rather than a 500. + */ +export function jalaliToIsoDate( + jy: number, + jm: number, + jd: number, +): string | null { + if (!isValidJalaliDate(jy, jm, jd)) return null; + const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); + return `${String(gy).padStart(4, '0')}-${String(gm).padStart(2, '0')}-${String(gd).padStart(2, '0')}`; +}