fuzzyworld

when-am-i / commit

feat: add Iran, Russian America, Oregon Country calendars; spell out month names

c7d226e115e6a60f0be966da834bf200dfb41530 · Pavel Safronov · 2026-06-20 11:53:12 -0700 · parents 30de6fafb2e8

feat: add Iran, Russian America, Oregon Country calendars; spell out month names

Three region files beyond Boston, proving the engine generalizes:
- iran.json — Solar Hijri + lunar Hijri (exercises the Temporal-backed Persian
  and Islamic converters). Chinese was the first pick for the convertible-library
  proof but is broken in @js-temporal/polyfill 0.5.1 (throws on every date), so
  Persian/Iran was used instead.
- russian-america.json — Julian -> Gregorian at the 1867 Alaska transfer.
- oregon-country.json — Hudson's Bay Company (Gregorian), covers the Seattle area.

Temporal calendars now spell out the month ("Muharram 3, 1112 AH" instead of
"1112-01-03") via Intl with the calendar — it carries each calendar's month
names/era and handles the Hebrew leap month; locale pinned to en-US.

Adds a regression test pinning these regions as convertible (validate:data does
not whitelist calendar names, so a typo'd name would silently degrade to
descriptive-only). Updates the calendar-data badge and plan.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 README.md                               |  2 +-
 plan.md                                 | 34 ++++++++++++++++++--
 src/data/calendars/iran.json            | 53 +++++++++++++++++++++++++++++++
 src/data/calendars/oregon-country.json  | 35 +++++++++++++++++++++
 src/data/calendars/russian-america.json | 55 +++++++++++++++++++++++++++++++++
 src/data/index.test.ts                  | 32 +++++++++++++++++++
 src/utils/calendars.test.ts             |  7 +++++
 src/utils/calendars.ts                  | 16 +++++++---
 8 files changed, 226 insertions(+), 8 deletions(-)

diff --git a/README.md b/README.md
index 5c0370f..3b48cb6 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
 [![pipeline status](https://gitlab.com/PavelSafronov/when-am-i/badges/main/pipeline.svg)](https://gitlab.com/PavelSafronov/when-am-i/-/pipelines)
 [![live demo](https://img.shields.io/badge/demo-live-success)](https://pavelsafronov.gitlab.io/when-am-i)
 ![version](https://img.shields.io/badge/version-v0.1.0-blue)
-![calendar data](https://img.shields.io/badge/calendar%20data-2-orange)
+![calendar data](https://img.shields.io/badge/calendar%20data-5-orange)
 ![license](https://img.shields.io/badge/license-MIT-green)
 
 ![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)
diff --git a/plan.md b/plan.md
index 9ffd077..c2cf035 100644
--- a/plan.md
+++ b/plan.md
@@ -200,13 +200,43 @@ A `screenshot:calendar -- <file.json>` script that renders **one calendar's** an
 2. **Engine** — geo + era matching + conversion registry, with tests. Gregorian/Julian first.
 3. **Library-backed calendars** — wire the chosen conversion lib (Q1), add tests.
 4. **UI** — DatePicker + ResultPanel + App wiring; handle all miss states.
-5. **Data pipeline** — JSON Schema + `validate:data` + CI gate.
-6. **Docs** — README + SCHEMA sync. Add 1–2 more region files to prove generalization.
+5. **Data pipeline** — ✅ `validate:data` + `prebuild`/CI gate (see note above; the
+   validator is the schema, no separate JSON Schema file).
+6. **Docs** — README + SCHEMA sync. ✅ Generalization proven 2026-06-20 with three
+   region files beyond Boston:
+   [iran.json](src/data/calendars/iran.json) (Solar Hijri + lunar Hijri, exercising the
+   Temporal-backed Persian and Islamic converters),
+   [russian-america.json](src/data/calendars/russian-america.json) (a second Julian→Gregorian
+   cutover, at the 1867 Alaska transfer), and
+   [oregon-country.json](src/data/calendars/oregon-country.json) (Hudson's Bay Company,
+   covering the Seattle area). Note: the Temporal **`chinese`** calendar was the
+   first pick for the convertible-library proof but is broken in
+   `@js-temporal/polyfill@0.5.1` (throws on every date — "Unexpected leap month
+   suffix"; no fixed version available), so **Persian/Iran** was used instead.
 
 ---
 
 ## Stretch goals (post-MVP)
 
+### AI-assisted dataset authoring (requested 2026-06-20)
+Add Claude Code **skill(s)** under `.claude/skills/` that help an author create a new
+calendar region file end-to-end, lowering the bar to contribute data. Sketch of what a
+"new calendar dataset" skill would do:
+- Take a place + people/polity as input (e.g. "the Mughal Empire", "Coast Salish around
+  Puget Sound") and **research** the calendar system(s), era boundaries, and *whose*
+  reckoning it is (`usedBy`), gathering **citable sources** (the project's acceptance gate
+  — never invent events or moon names; see "Data accuracy" below).
+- Draft the **region polygon** (GeoJSON `[lng, lat]`, closed rings) and the eras with
+  proleptic-Gregorian `start`/`end`, choosing the right `calendar` string so it hits an
+  existing converter (or flagging it as descriptive-only).
+- Decide **convertible vs descriptive-only** correctly, and if a new convertible system is
+  needed, note the converter work in [src/utils/calendars.ts](src/utils/calendars.ts)
+  (with the `@js-temporal/polyfill` caveat — e.g. `chinese` is currently broken).
+- Run `npm run validate:data`, `npm run gen:badges`, and
+  `npm run screenshot:calendar -- <file>` so the output is PR-ready with review screenshots.
+- Possibly a second, smaller skill to **review/QA** an existing dataset against its sources.
+This is a tooling/authoring aid, not app runtime; keep it in `.claude/`, not shipped to users.
+
 ### Rich descriptive-only answers
 For non-convertible calendars, replace the plain "no year count" note with the kind of
 answer a real local would give. Example target for **Boston, 1700-04-01**, asked of a
diff --git a/src/data/calendars/iran.json b/src/data/calendars/iran.json
new file mode 100644
index 0000000..d929c37
--- /dev/null
+++ b/src/data/calendars/iran.json
@@ -0,0 +1,53 @@
+{
+    "region": {
+        "name": "Iran (historic Persia)",
+        "geo": {
+            "type": "Polygon",
+            "coordinates": [
+                [
+                    [44.0, 39.5],
+                    [48.0, 38.5],
+                    [53.0, 37.0],
+                    [56.0, 38.0],
+                    [61.0, 36.5],
+                    [61.5, 31.0],
+                    [62.5, 27.5],
+                    [58.0, 25.5],
+                    [53.0, 26.5],
+                    [48.5, 30.0],
+                    [45.5, 33.5],
+                    [44.5, 36.5],
+                    [44.0, 39.5]
+                ]
+            ]
+        }
+    },
+    "eras": [
+        {
+            "name": "Lunar Hijri era",
+            "start": "0651-01-01",
+            "end": "1925-03-21",
+            "calendar": "Islamic",
+            "usedBy": "Persians / Iranians",
+            "description": "Following the 7th-century Muslim conquest of Persia, the lunar Hijri (Islamic) calendar was widely used for religious and much civil dating in Persia, alongside various solar court calendars (such as the Jalali calendar devised in 1079), until a solar calendar became the official civil calendar in 1925.",
+            "sources": [
+                "https://en.wikipedia.org/wiki/Iranian_calendars",
+                "https://en.wikipedia.org/wiki/Islamic_calendar",
+                "https://en.wikipedia.org/wiki/Muslim_conquest_of_Persia"
+            ]
+        },
+        {
+            "name": "Solar Hijri era",
+            "start": "1925-03-21",
+            "end": null,
+            "calendar": "Persian",
+            "usedBy": "Iranians",
+            "description": "In 1925 the Solar Hijri calendar became the official civil calendar of Iran, replacing the lunar Hijri calendar for civil use. It is a solar calendar whose year begins at the northern spring equinox (Nowruz) and counts years from the Hijra, so it remains in use today (year 1404 began in March 2025).",
+            "sources": [
+                "https://en.wikipedia.org/wiki/Solar_Hijri_calendar",
+                "https://en.wikipedia.org/wiki/Iranian_calendars",
+                "https://en.wikipedia.org/wiki/Nowruz"
+            ]
+        }
+    ]
+}
diff --git a/src/data/calendars/oregon-country.json b/src/data/calendars/oregon-country.json
new file mode 100644
index 0000000..51b62ef
--- /dev/null
+++ b/src/data/calendars/oregon-country.json
@@ -0,0 +1,35 @@
+{
+    "region": {
+        "name": "Oregon Country",
+        "geo": {
+            "type": "Polygon",
+            "coordinates": [
+                [
+                    [-124.6, 48.4],
+                    [-123.0, 49.0],
+                    [-117.0, 49.0],
+                    [-116.5, 46.0],
+                    [-117.0, 42.0],
+                    [-124.4, 42.0],
+                    [-124.6, 48.4]
+                ]
+            ]
+        }
+    },
+    "eras": [
+        {
+            "name": "Joint occupation (Hudson's Bay Company)",
+            "start": "1818-10-20",
+            "end": "1846-06-15",
+            "calendar": "Gregorian",
+            "usedBy": "British fur traders and the Hudson's Bay Company",
+            "description": "Under the Anglo-American Convention of 1818, the Oregon Country (from 42°N to 54°40′N) was jointly occupied by Britain and the United States. The Hudson's Bay Company's Columbia Department, run from Fort Vancouver (1825) with posts such as Fort Nisqually near present-day Tacoma, dominated the region. Britain had used the Gregorian calendar since 1752. Joint occupation ended with the Oregon Treaty of 1846, which set the border at the 49th parallel.",
+            "sources": [
+                "https://en.wikipedia.org/wiki/Oregon_Country",
+                "https://en.wikipedia.org/wiki/Hudson%27s_Bay_Company",
+                "https://en.wikipedia.org/wiki/Treaty_of_1818",
+                "https://en.wikipedia.org/wiki/Oregon_Treaty"
+            ]
+        }
+    ]
+}
diff --git a/src/data/calendars/russian-america.json b/src/data/calendars/russian-america.json
new file mode 100644
index 0000000..e893fa9
--- /dev/null
+++ b/src/data/calendars/russian-america.json
@@ -0,0 +1,55 @@
+{
+    "region": {
+        "name": "Russian America (Alaska)",
+        "geo": {
+            "type": "Polygon",
+            "coordinates": [
+                [
+                    [-130.8, 55.5],
+                    [-134.0, 58.0],
+                    [-138.0, 59.2],
+                    [-142.0, 60.3],
+                    [-146.0, 61.0],
+                    [-150.0, 61.4],
+                    [-153.0, 60.4],
+                    [-155.2, 58.8],
+                    [-154.2, 57.4],
+                    [-151.8, 56.7],
+                    [-150.0, 58.6],
+                    [-145.5, 60.0],
+                    [-140.0, 59.5],
+                    [-136.5, 56.9],
+                    [-133.0, 55.2],
+                    [-130.8, 55.5]
+                ]
+            ]
+        }
+    },
+    "eras": [
+        {
+            "name": "Russian America (Old Style)",
+            "start": "1784-08-14",
+            "end": "1867-10-18",
+            "calendar": "Julian",
+            "usedBy": "Russian colonists and the Russian-American Company",
+            "description": "From the first permanent Russian settlement at Three Saints Bay, Kodiak (1784), and under the Russian-American Company (chartered 1799) governed from New Archangel (Sitka), Russian America used the Julian calendar ('Old Style'), as in the Russian Empire, until the territory was sold to the United States in 1867.",
+            "sources": [
+                "https://en.wikipedia.org/wiki/Russian_America",
+                "https://en.wikipedia.org/wiki/Russian-American_Company",
+                "https://en.wikipedia.org/wiki/Julian_calendar"
+            ]
+        },
+        {
+            "name": "Alaska (United States)",
+            "start": "1867-10-18",
+            "end": null,
+            "calendar": "Gregorian",
+            "usedBy": "the United States",
+            "description": "At the formal transfer of Alaska to the United States on 18 October 1867 (Gregorian), the calendar switched from Julian to Gregorian. The change skipped the 12-day Old/New Style gap and adjusted for the international date line, so Friday 6 October (Julian) was followed by Friday 18 October (Gregorian).",
+            "sources": [
+                "https://en.wikipedia.org/wiki/Alaska_Purchase",
+                "https://en.wikipedia.org/wiki/Gregorian_calendar"
+            ]
+        }
+    ]
+}
diff --git a/src/data/index.test.ts b/src/data/index.test.ts
index 7b8d9f8..5d27d68 100644
--- a/src/data/index.test.ts
+++ b/src/data/index.test.ts
@@ -1,5 +1,7 @@
 import { describe, it, expect } from 'vitest';
 import { regions } from './index';
+import { convert } from '../utils/calendars';
+import { parseGregorianDate } from '../utils/julianDay';
 
 // Smoke test: the data loader must validate and expose the calendar files.
 // As more regions are added this also acts as a basic data sanity check.
@@ -29,4 +31,34 @@ describe('calendar data loader', () => {
             }
         }
     });
+
+    // Regression guard: validate:data does not whitelist calendar names, so a
+    // typo (e.g. "Persain") would pass validation but silently make a region
+    // descriptive-only. These regions are meant to convert to a numeric year, so
+    // pin that here. The date is the midpoint of each era's covered range.
+    it.each([
+        ['Iran (historic Persia)', 'Lunar Hijri era', 'Islamic (Umm al-Qura)'],
+        ['Iran (historic Persia)', 'Solar Hijri era', 'Persian (Solar Hijri)'],
+        ['Russian America (Alaska)', 'Russian America (Old Style)', 'Julian'],
+        ['Russian America (Alaska)', 'Alaska (United States)', 'Gregorian'],
+        ['Oregon Country', 'Joint occupation (Hudson\'s Bay Company)', 'Gregorian'],
+    ])('%s / %s converts to a numeric year (%s)', (regionName, eraName, system) => {
+        const region = regions.find((r) => r.region.name === regionName);
+        expect(region, `region "${regionName}" not loaded`).toBeDefined();
+        const era = region!.eras.find((e) => e.name === eraName);
+        expect(era, `era "${eraName}" not found`).toBeDefined();
+
+        // Pick a date inside the era's range (midpoint, or near a null bound).
+        const startJd = era!.start ? parseGregorianDate(era!.start) : null;
+        const endJd = era!.end ? parseGregorianDate(era!.end) : null;
+        const year = startJd && endJd
+            ? Math.floor((startJd.year + endJd.year) / 2)
+            : startJd ? startJd.year + 10
+                : endJd ? endJd.year - 10
+                    : 1850;
+
+        const conversion = convert({ year, month: 7, day: 1 }, era!.calendar);
+        expect(conversion.convertible, `${regionName}/${eraName} should be convertible`).toBe(true);
+        expect(conversion.system).toBe(system);
+    });
 });
diff --git a/src/utils/calendars.test.ts b/src/utils/calendars.test.ts
index d32c45b..d3ba6d3 100644
--- a/src/utils/calendars.test.ts
+++ b/src/utils/calendars.test.ts
@@ -46,6 +46,13 @@ describe('convert', () => {
         expect(convert({ year: 2000, month: 1, day: 1 }, 'Buddhist').localYearLabel).toBe('BE 2543');
     });
 
+    it('spells out the month name in the date label', () => {
+        // 1700-06-20 Gregorian falls in Muharram (the first Islamic month).
+        expect(convert({ year: 1700, month: 6, day: 20 }, 'Islamic').localDateLabel).toMatch(/Muharram/);
+        // 2025-03-25 Gregorian falls in Farvardin (the first Persian month).
+        expect(convert({ year: 2025, month: 3, day: 25 }, 'Persian').localDateLabel).toMatch(/Farvardin/);
+    });
+
     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 2e7ae7d..65247b2 100644
--- a/src/utils/calendars.ts
+++ b/src/utils/calendars.ts
@@ -14,10 +14,6 @@ import { Temporal } from '@js-temporal/polyfill';
 import type { CalendarConversion, HistoricalDate } from '../types';
 import { gregorianToJulianDay, julianDayToJulian } from './julianDay';
 
-function pad2(n: number): string {
-    return String(Math.abs(n)).padStart(2, '0');
-}
-
 /** Year label in astronomical numbering: year <= 0 renders as "N BC". */
 function yearLabel(year: number): string {
     return year > 0 ? String(year) : `${1 - year} BC`;
@@ -100,7 +96,17 @@ function temporalConverter(def: TemporalCalendarDef): Converter {
             return {
                 system: def.system,
                 localYearLabel: `${def.eraLabel} ${local.year}`,
-                localDateLabel: `${local.year}-${pad2(local.month)}-${pad2(local.day)}`,
+                // Spell out the month (e.g. "Muharram 3, 1112 AH") so the answer
+                // reads like a local date rather than a bare numeric "1112-01-03".
+                // Intl carries each calendar's own month names and era, and
+                // handles oddities like the Hebrew leap month (Adar I/II). Locale
+                // is pinned to en-US so the label is the same for every visitor.
+                localDateLabel: local.toLocaleString('en-US', {
+                    calendar: def.calendarId,
+                    year: 'numeric',
+                    month: 'long',
+                    day: 'numeric',
+                }),
                 convertible: true,
             };
         } catch {