when-am-i / commit
feat: convert library-backed calendars via Temporal
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.', + ); }