when-am-i / commit
feat: add Japanese, Ethiopian, and Chinese sexagenary converters
src/utils/calendars.test.ts | 30 +++++++++++++++ src/utils/calendars.ts | 91 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/utils/calendars.test.ts b/src/utils/calendars.test.ts index d3ba6d3..25f9d90 100644 --- a/src/utils/calendars.test.ts +++ b/src/utils/calendars.test.ts @@ -53,6 +53,36 @@ describe('convert', () => { expect(convert({ year: 2025, month: 3, day: 25 }, 'Persian').localDateLabel).toMatch(/Farvardin/); }); + it('converts to the Japanese nengō era (Meiji onward)', () => { + expect(convert({ year: 2024, month: 6, day: 1 }, 'Japanese').localYearLabel).toBe('Reiwa 6'); + expect(convert({ year: 1873, month: 1, day: 15 }, 'Japanese').localYearLabel).toBe('Meiji 6'); + // Pre-Meiji has no modern nengō numbering → descriptive-only fallback. + const old = convert({ year: 1700, month: 1, day: 1 }, 'Japanese'); + expect(old.convertible).toBe(false); + expect(old.localYearLabel).toBe('1700'); // Gregorian reference + }); + + it('converts to the Ethiopian calendar (hand-rolled)', () => { + // 2000-01-01 Gregorian falls in Ethiopian year 1992 (new year was 1999-09-11/12). + const c = convert({ year: 2000, month: 1, day: 1 }, 'Ethiopian'); + expect(c).toMatchObject({ system: 'Ethiopian', convertible: true }); + expect(c.localYearLabel).toBe('1992 E.C.'); + expect(c.localDateLabel).toBe('Tahsas 22, 1992 E.C.'); + // A date just after Ethiopian New Year ticks the year over. + expect(convert({ year: 2024, month: 9, day: 20 }, 'Ethiopian').localYearLabel).toBe('2017 E.C.'); + }); + + it('names the Chinese sexagenary year (descriptive-only)', () => { + // 1984 began a new 60-year cycle: Jiǎ-zǐ, the (Wood) Rat. + const c = convert({ year: 1984, month: 6, day: 1 }, 'Chinese'); + expect(c.convertible).toBe(false); // named cycle, not a numbered year + expect(c.system).toBe('Chinese sexagenary cycle'); + expect(c.localDateLabel).toMatch(/Jiǎ-Zǐ/); + expect(c.localDateLabel).toMatch(/Wood Rat/); + expect(c.localYearLabel).toBe('1984'); // Gregorian reference + expect(convert({ year: 2024, month: 6, day: 1 }, 'Chinese').localDateLabel).toMatch(/Wood Dragon/); + }); + it('falls back to descriptive-only for unknown calendars', () => { const c = convert({ year: 1700, month: 4, day: 1 }, 'Some Unknown Calendar'); expect(c.convertible).toBe(false); diff --git a/src/utils/calendars.ts b/src/utils/calendars.ts index 65247b2..1daa457 100644 --- a/src/utils/calendars.ts +++ b/src/utils/calendars.ts @@ -116,6 +116,92 @@ function temporalConverter(def: TemporalCalendarDef): Converter { }; } +// ── Japanese (imperial nengō era) ──────────────────────────────────────────── +// Temporal's `japanese` calendar works, but PlainDate.year returns the ISO year; +// the regnal-era year a Japanese local cites is `eraYear` under an `era` (e.g. +// Reiwa 6). Intl only knows the modern nengō (Meiji onward), so a region using +// this converter should scope it to 1873+ (Japan's Gregorian adoption) — earlier +// Japanese dating used the lunisolar calendar (descriptive-only). +function japaneseConverter(date: HistoricalDate): CalendarConversion { + try { + const local = Temporal.PlainDate + .from({ year: date.year, month: date.month, day: date.day }) + .withCalendar('japanese'); + const era = local.era ?? ''; + // Intl only knows the modern nengō (Meiji+); older dates fall into a + // generic 'japanese' bucket with the ISO year, which we don't label as a + // regnal era. Regions should scope this converter to 1873+ regardless. + if (era === 'japanese') { + return descriptiveOnly(date, 'Japanese (nengō)', 'The modern regnal-era (nengō) numbering is defined from the Meiji era (1868); showing the Gregorian year for reference.'); + } + const eraName = era.charAt(0).toUpperCase() + era.slice(1); // 'reiwa' → 'Reiwa' + return { + system: 'Japanese (nengō)', + localYearLabel: `${eraName} ${local.eraYear}`, + localDateLabel: local.toLocaleString('en-US', { + calendar: 'japanese', era: 'long', year: 'numeric', month: 'long', day: 'numeric', + }), + convertible: true, + }; + } catch { + return descriptiveOnly(date, 'Japanese (nengō)', 'Conversion is unavailable for this date; showing the Gregorian year for reference.'); + } +} + +// ── Ethiopian calendar (hand-rolled) ───────────────────────────────────────── +// @js-temporal/polyfill@0.5.1 throws on the `ethiopic` calendar ("Era am … was +// not matched"), so we convert via the Julian Day Number ourselves. The +// Ethiopian (Amete Mihret) calendar: 12 months of 30 days + a 5- or 6-day 13th +// month (Pagume), leap year when year ≡ 3 (mod 4). Epoch 1 Meskerem 1 = JDN +// 1724221 (29 Aug 8 CE Julian); algorithm after Dershowitz & Reingold. +const ETHIOPIC_EPOCH_JDN = 1724221; +const ETHIOPIC_MONTHS = [ + 'Meskerem', 'Tikimt', 'Hidar', 'Tahsas', 'Tir', 'Yakatit', 'Megabit', + 'Miazia', 'Ginbot', 'Sene', 'Hamle', 'Nehase', 'Pagume', +]; + +function ethiopicConverter(date: HistoricalDate): CalendarConversion { + const r = gregorianToJulianDay(date) - ETHIOPIC_EPOCH_JDN; + const year = Math.floor((4 * r + 1463) / 1461); + const dayInYear = r - (365 * (year - 1) + Math.floor(year / 4)); // 0-based + const month = Math.floor(dayInYear / 30) + 1; + const day = (dayInYear % 30) + 1; + return { + system: 'Ethiopian', + localYearLabel: `${year} E.C.`, + localDateLabel: `${ETHIOPIC_MONTHS[month - 1]} ${day}, ${year} E.C.`, + convertible: true, + }; +} + +// ── Chinese sexagenary cycle (hand-rolled, descriptive) ────────────────────── +// The lunisolar `chinese` calendar throws in the polyfill, and its year is named, +// not numbered — a repeating 60-year cycle of a Heavenly Stem + Earthly Branch, +// which pairs with one of five elements and twelve zodiac animals. That cycle is +// deterministic from the year, so we name it; but because it has no continuous +// numbered year, it is descriptive-only (we still show the Gregorian reference). +const STEMS = ['Jiǎ', 'Yǐ', 'Bǐng', 'Dīng', 'Wù', 'Jǐ', 'Gēng', 'Xīn', 'Rén', 'Guǐ']; +const BRANCHES = ['Zǐ', 'Chǒu', 'Yín', 'Mǎo', 'Chén', 'Sì', 'Wǔ', 'Wèi', 'Shēn', 'Yǒu', 'Xū', 'Hài']; +const ELEMENTS = ['Wood', 'Fire', 'Earth', 'Metal', 'Water']; +const ANIMALS = ['Rat', 'Ox', 'Tiger', 'Rabbit', 'Dragon', 'Snake', 'Horse', 'Goat', 'Monkey', 'Rooster', 'Dog', 'Pig']; + +function sexagenaryConverter(date: HistoricalDate): CalendarConversion { + // The sexagenary year turns at Chinese New Year (late Jan–Feb), which needs + // the lunisolar calendar to place exactly; we approximate with the Gregorian + // year and flag the caveat. Year n: stem = (n−4) mod 10, branch = (n−4) mod 12. + const n = date.year; + const stem = (((n - 4) % 10) + 10) % 10; + const branch = (((n - 4) % 12) + 12) % 12; + const name = `${STEMS[stem]}-${BRANCHES[branch]} — year of the ${ELEMENTS[Math.floor(stem / 2)]} ${ANIMALS[branch]}`; + return { + system: 'Chinese sexagenary cycle', + localYearLabel: yearLabel(date.year), // Gregorian reference year + localDateLabel: name, + convertible: false, + note: 'Years are named in a repeating 60-year cycle (a Heavenly Stem + Earthly Branch, with one of five elements and twelve zodiac animals), not numbered. The year turns at Chinese New Year (late January–February), so a date in January or early February may still belong to the previous year shown here.', + }; +} + const CONVERTERS: Record<string, Converter> = { gregorian: (date) => ({ system: 'Gregorian', @@ -132,6 +218,11 @@ const CONVERTERS: Record<string, Converter> = { convertible: true, }; }, + japanese: japaneseConverter, + ethiopian: ethiopicConverter, + ethiopic: ethiopicConverter, + 'chinese sexagenary cycle': sexagenaryConverter, + chinese: sexagenaryConverter, }; for (const { names, def } of TEMPORAL_CALENDARS) {