fuzzyworld

when-am-i / commit

feat: convert library-backed calendars via Temporal

d7aad2c59d39d45de5da6a6a5a8d0183b8cb1c06 · Pavel Safronov · 2026-06-18 14:11:21 -0700 · parents a71096e665b1

feat: convert library-backed calendars via Temporal

Add @js-temporal/polyfill and extend the conversion registry (table-driven) to
cover calendars with a single continuous year count:

- Islamic (Umm al-Qura / Hijri) → AH
- Hebrew → AM
- Persian (Solar Hijri) → SH
- Indian (Saka) → Saka
- Buddhist → BE

Each conversion is wrapped so a date outside the calendar's supported range
degrades to descriptive-only rather than throwing. Calendars the polyfill can't
handle (Coptic, Ethiopic, Chinese) continue to hit the descriptive-only path.
Anchored to 2000-01-01 in tests (AH 1420, AM 5760, SH 1378, Saka 1921, BE 2543).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 package-lock.json           | 19 +++++++++++++++
 package.json                |  1 +
 src/utils/calendars.test.ts | 23 ++++++++++++++++++
 src/utils/calendars.ts      | 59 ++++++++++++++++++++++++++++++++++++++++-----
 4 files changed, 96 insertions(+), 6 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 8460d40..5875ac2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
             "version": "0.1.0",
             "hasInstallScript": true,
             "dependencies": {
+                "@js-temporal/polyfill": "^0.5.1",
                 "leaflet": "^1.9.4",
                 "react": "^18.0.0",
                 "react-dom": "^18.0.0",
@@ -1035,6 +1036,18 @@
                 "@jridgewell/sourcemap-codec": "^1.4.14"
             }
         },
+        "node_modules/@js-temporal/polyfill": {
+            "version": "0.5.1",
+            "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz",
+            "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==",
+            "license": "ISC",
+            "dependencies": {
+                "jsbi": "^4.3.0"
+            },
+            "engines": {
+                "node": ">=12"
+            }
+        },
         "node_modules/@napi-rs/wasm-runtime": {
             "version": "1.1.5",
             "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -2896,6 +2909,12 @@
             "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
             "license": "MIT"
         },
+        "node_modules/jsbi": {
+            "version": "4.3.2",
+            "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz",
+            "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==",
+            "license": "Apache-2.0"
+        },
         "node_modules/jsesc": {
             "version": "3.1.0",
             "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
diff --git a/package.json b/package.json
index a764d7f..b3dafae 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
         "postinstall": "npm run copy:leaflet-icons"
     },
     "dependencies": {
+        "@js-temporal/polyfill": "^0.5.1",
         "leaflet": "^1.9.4",
         "react": "^18.0.0",
         "react-dom": "^18.0.0",
diff --git a/src/utils/calendars.test.ts b/src/utils/calendars.test.ts
index 8bb4604..ac90b04 100644
--- a/src/utils/calendars.test.ts
+++ b/src/utils/calendars.test.ts
@@ -23,6 +23,29 @@ describe('convert', () => {
         expect(convert({ year: 0, month: 1, day: 1 }, 'Gregorian').localYearLabel).toBe('1 BC');
     });
 
+    // Library-backed (Temporal) calendars, anchored to 2000-01-01 Gregorian.
+    it('converts to the Islamic (Umm al-Qura) year', () => {
+        const c = convert({ year: 2000, month: 1, day: 1 }, 'Islamic');
+        expect(c.convertible).toBe(true);
+        expect(c.system).toBe('Islamic (Umm al-Qura)');
+        expect(c.localYearLabel).toBe('AH 1420');
+    });
+
+    it('converts to the Hebrew year', () => {
+        const c = convert({ year: 2000, month: 1, day: 1 }, 'Hebrew');
+        expect(c).toMatchObject({ convertible: true, localYearLabel: 'AM 5760' });
+    });
+
+    it('converts to the Persian (Solar Hijri) year', () => {
+        const c = convert({ year: 2000, month: 1, day: 1 }, 'Persian');
+        expect(c).toMatchObject({ convertible: true, localYearLabel: 'SH 1378' });
+    });
+
+    it('converts to the Indian (Saka) and Buddhist years', () => {
+        expect(convert({ year: 2000, month: 1, day: 1 }, 'Saka').localYearLabel).toBe('Saka 1921');
+        expect(convert({ year: 2000, month: 1, day: 1 }, 'Buddhist').localYearLabel).toBe('BE 2543');
+    });
+
     it('falls back to descriptive-only for unknown calendars', () => {
         const c = convert({ year: 1700, month: 4, day: 1 }, 'Algonquian lunar calendar');
         expect(c.convertible).toBe(false);
diff --git a/src/utils/calendars.ts b/src/utils/calendars.ts
index 70703ff..d109d8b 100644
--- a/src/utils/calendars.ts
+++ b/src/utils/calendars.ts
@@ -6,7 +6,11 @@
 // i.e. a deterministic epoch+rule yields a numeric local year. Calendars without
 // one (e.g. an observational lunar calendar) are descriptive-only: we return the
 // Gregorian year for reference and convertible:false, never a wrong number.
+//
+// Gregorian and Julian are hand-rolled (julianDay.ts); the remaining
+// library-backed calendars go through @js-temporal/polyfill.
 
+import { Temporal } from '@js-temporal/polyfill';
 import type { CalendarConversion, HistoricalDate } from '../types';
 import { gregorianToJulianDay, julianDayToJulian } from './julianDay';
 
@@ -23,8 +27,47 @@ function dateLabel({ year, month, day }: HistoricalDate): string {
     return `${year}-${pad2(month)}-${pad2(day)}`;
 }
 
+function descriptiveOnly(date: HistoricalDate, system: string, note: string): CalendarConversion {
+    return { system, localYearLabel: yearLabel(date.year), convertible: false, note };
+}
+
 type Converter = (date: HistoricalDate) => CalendarConversion;
 
+// Temporal calendar identifiers that resolve to a single continuous year count.
+// `eraLabel` prefixes the year (e.g. "AH 1420"); the system names the calendar.
+interface TemporalCalendarDef {
+    calendarId: string;
+    system: string;
+    eraLabel: string;
+}
+
+const TEMPORAL_CALENDARS: { names: string[]; def: TemporalCalendarDef }[] = [
+    { names: ['islamic', 'islamic-umalqura', 'hijri'], def: { calendarId: 'islamic-umalqura', system: 'Islamic (Umm al-Qura)', eraLabel: 'AH' } },
+    { names: ['hebrew'], def: { calendarId: 'hebrew', system: 'Hebrew', eraLabel: 'AM' } },
+    { names: ['persian', 'solar hijri'], def: { calendarId: 'persian', system: 'Persian (Solar Hijri)', eraLabel: 'SH' } },
+    { names: ['indian', 'saka'], def: { calendarId: 'indian', system: 'Indian (Saka)', eraLabel: 'Saka' } },
+    { names: ['buddhist'], def: { calendarId: 'buddhist', system: 'Buddhist', eraLabel: 'BE' } },
+];
+
+function temporalConverter(def: TemporalCalendarDef): Converter {
+    return (date) => {
+        try {
+            const local = Temporal.PlainDate
+                .from({ year: date.year, month: date.month, day: date.day })
+                .withCalendar(def.calendarId);
+            return {
+                system: def.system,
+                localYearLabel: `${def.eraLabel} ${local.year}`,
+                localDateLabel: `${local.year}-${pad2(local.month)}-${pad2(local.day)}`,
+                convertible: true,
+            };
+        } catch {
+            // e.g. a date before the calendar's supported range.
+            return descriptiveOnly(date, def.system, 'Conversion is unavailable for this date; showing the Gregorian year for reference.');
+        }
+    };
+}
+
 const CONVERTERS: Record<string, Converter> = {
     gregorian: (date) => ({
         system: 'Gregorian',
@@ -43,6 +86,11 @@ const CONVERTERS: Record<string, Converter> = {
     },
 };
 
+for (const { names, def } of TEMPORAL_CALENDARS) {
+    const converter = temporalConverter(def);
+    for (const name of names) CONVERTERS[name] = converter;
+}
+
 /** Convert `date` into the named calendar's local year. */
 export function convert(date: HistoricalDate, calendarName: string): CalendarConversion {
     const converter = CONVERTERS[calendarName.trim().toLowerCase()];
@@ -50,10 +98,9 @@ export function convert(date: HistoricalDate, calendarName: string): CalendarCon
 
     // Descriptive-only: keep the original calendar name, show the Gregorian
     // year for reference, and be explicit that no numeric year exists.
-    return {
-        system: calendarName,
-        localYearLabel: yearLabel(date.year),
-        convertible: false,
-        note: 'This calendar has no continuous numeric year to convert to; showing the Gregorian year for reference.',
-    };
+    return descriptiveOnly(
+        date,
+        calendarName,
+        'This calendar has no continuous numeric year to convert to; showing the Gregorian year for reference.',
+    );
 }