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) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 17:00:41 +03:30
parent 648e8ed1f2
commit 80ffa37edd
2 changed files with 291 additions and 0 deletions

View File

@@ -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',
);
});
});
});

View File

@@ -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+06F0U+06F9) zero, and Arabic-Indic (U+0660U+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')}`;
}