fuzzyworld

when-am-i / commit

feat: add Japanese, Ethiopian, and Chinese sexagenary converters

4c1ec1d7830a61e78126f1575ddbe21461dfb829 · Pavel Safronov · 2026-06-20 18:30:29 -0700 · parents 862df8d39c0b

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 3 tally 29.0K in / 269.0K out / 22.9M cached · usage by commit and session

feat: add Japanese, Ethiopian, and Chinese sexagenary converters

Three new calendar converters in the registry:
- Japanese (nengō): Temporal `japanese`, read via era/eraYear (Meiji onward);
  pre-Meiji dates fall back to descriptive-only.
- Ethiopian: hand-rolled via the Julian Day Number, because Temporal's
  `ethiopic` (and `coptic`) calendars throw in @js-temporal/polyfill@0.5.1.
- Chinese sexagenary cycle: hand-rolled stem-branch / element / zodiac-animal
  year naming (the lunisolar `chinese` calendar also throws); descriptive-only,
  since the cycle names years rather than numbering them.

Usage: 29.0K in / 269.0K out / 22.9M cached (session tally)
rtk:   110.8K saved, 80.9% over 243 cmds (global/lifetime)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 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) {