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:
99
backend/src/common/jalali.spec.ts
Normal file
99
backend/src/common/jalali.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
192
backend/src/common/jalali.ts
Normal file
192
backend/src/common/jalali.ts
Normal 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+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')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user