fuzzyworld

when-am-i / commit

feat: add resolution engine (geo, era matching, calendar conversion)

a71096e665b127021c0992a52a9f251015dc3e46 · Pavel Safronov · 2026-06-17 23:09:12 -0700 · parents 591c237336cb

feat: add resolution engine (geo, era matching, calendar conversion)

The pure core that answers "what year is it?" at a point and date, built as
composable, independently-tested layers:

- julianDay.ts: proleptic-Gregorian <-> Julian Day Number and JDN -> Julian
  calendar (Fliegel/Van Flandern + Richards), plus YYYY-MM-DD parsing. Uses
  astronomical year numbering so BCE/CE and pre-1582 dates order correctly.
- geo.ts: ray-cast pointInPolygon (Polygon/MultiPolygon + holes) and findRegions
  returning every region that contains the point (regions may overlap).
- eras.ts: findEras over half-open [start, end) intervals (null = open), and
  returning all overlapping eras (parallel calendars coexist).
- calendars.ts: convert registry — Gregorian (identity) + hand-rolled Julian,
  with a descriptive-only fallback for calendars that have no continuous numeric
  year (e.g. observational lunar calendars).
- resolve.ts: composes the above into the plural-answers Resolution.

Anchored to known facts in tests (J2000 epoch, the 13- and 10-day Julian gaps,
half-open boundaries, overlapping eras, polygon holes). 32 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 src/utils/calendars.test.ts |  34 +++++++++++++
 src/utils/calendars.ts      |  59 +++++++++++++++++++++++
 src/utils/eras.test.ts      |  40 ++++++++++++++++
 src/utils/eras.ts           |  20 ++++++++
 src/utils/geo.test.ts       | 113 ++++++++++++++++++++++++++++++++++++++++++++
 src/utils/geo.ts            |  42 ++++++++++++++++
 src/utils/julianDay.test.ts |  58 +++++++++++++++++++++++
 src/utils/julianDay.ts      |  72 ++++++++++++++++++++++++++++
 src/utils/resolve.test.ts   |  60 +++++++++++++++++++++++
 src/utils/resolve.ts        |  30 ++++++++++++
 10 files changed, 528 insertions(+)

diff --git a/src/utils/calendars.test.ts b/src/utils/calendars.test.ts
new file mode 100644
index 0000000..8bb4604
--- /dev/null
+++ b/src/utils/calendars.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest';
+import { convert } from './calendars';
+
+describe('convert', () => {
+    it('Gregorian is the identity on the input year', () => {
+        const c = convert({ year: 1700, month: 3, day: 22 }, 'Gregorian');
+        expect(c).toMatchObject({ system: 'Gregorian', localYearLabel: '1700', convertible: true });
+        expect(c.localDateLabel).toBe('1700-03-22');
+    });
+
+    it('is case/space-insensitive on the calendar name', () => {
+        expect(convert({ year: 1700, month: 1, day: 1 }, '  jUlIaN ').system).toBe('Julian');
+    });
+
+    it('Julian converts 1582-10-15 Gregorian to 1582-10-05', () => {
+        const c = convert({ year: 1582, month: 10, day: 15 }, 'Julian');
+        expect(c).toMatchObject({ system: 'Julian', localYearLabel: '1582', convertible: true });
+        expect(c.localDateLabel).toBe('1582-10-05');
+    });
+
+    it('labels BCE years using astronomical numbering', () => {
+        // Astronomical year 0 == 1 BCE.
+        expect(convert({ year: 0, month: 1, day: 1 }, 'Gregorian').localYearLabel).toBe('1 BC');
+    });
+
+    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);
+        expect(c.system).toBe('Algonquian lunar calendar');
+        expect(c.localYearLabel).toBe('1700'); // Gregorian reference
+        expect(c.note).toBeTruthy();
+        expect(c.localDateLabel).toBeUndefined();
+    });
+});
diff --git a/src/utils/calendars.ts b/src/utils/calendars.ts
new file mode 100644
index 0000000..70703ff
--- /dev/null
+++ b/src/utils/calendars.ts
@@ -0,0 +1,59 @@
+// Calendar conversion registry.
+//
+// `convert` turns a (proleptic-Gregorian) date into the local year for a named
+// calendar system. Two gates (see plan.md, Q3): a calendar is *accepted* into
+// the app as a data decision; here we only decide whether it is *convertible* —
+// 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.
+
+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`;
+}
+
+function dateLabel({ year, month, day }: HistoricalDate): string {
+    return `${year}-${pad2(month)}-${pad2(day)}`;
+}
+
+type Converter = (date: HistoricalDate) => CalendarConversion;
+
+const CONVERTERS: Record<string, Converter> = {
+    gregorian: (date) => ({
+        system: 'Gregorian',
+        localYearLabel: yearLabel(date.year),
+        localDateLabel: dateLabel(date),
+        convertible: true,
+    }),
+    julian: (date) => {
+        const julian = julianDayToJulian(gregorianToJulianDay(date));
+        return {
+            system: 'Julian',
+            localYearLabel: yearLabel(julian.year),
+            localDateLabel: dateLabel(julian),
+            convertible: true,
+        };
+    },
+};
+
+/** Convert `date` into the named calendar's local year. */
+export function convert(date: HistoricalDate, calendarName: string): CalendarConversion {
+    const converter = CONVERTERS[calendarName.trim().toLowerCase()];
+    if (converter) return converter(date);
+
+    // 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.',
+    };
+}
diff --git a/src/utils/eras.test.ts b/src/utils/eras.test.ts
new file mode 100644
index 0000000..697c600
--- /dev/null
+++ b/src/utils/eras.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from 'vitest';
+import { findEras } from './eras';
+import type { Era } from '../types';
+
+const colonial: Era = { name: 'Colonial', start: '1750-01-01', end: null, calendar: 'Gregorian' };
+const preColonial: Era = { name: 'Pre-Colonial', start: null, end: '1750-01-01', calendar: 'Algonquian lunar calendar' };
+// Deliberately overlaps both of the above around the 1750 boundary.
+const transitional: Era = { name: 'Transitional', start: '1700-01-01', end: '1800-01-01', calendar: 'Julian' };
+
+const eras = [colonial, preColonial, transitional];
+const names = (date: { year: number; month: number; day: number }) =>
+    findEras(date, eras).map((e) => e.name);
+
+describe('findEras', () => {
+    it('matches an open-past era before the boundary', () => {
+        expect(names({ year: 1600, month: 6, day: 1 })).toEqual(['Pre-Colonial']);
+    });
+
+    it('matches an open-present era after the boundary', () => {
+        expect(names({ year: 1900, month: 1, day: 1 })).toEqual(['Colonial']);
+    });
+
+    it('is half-open: the end date belongs to the next era, not the ending one', () => {
+        // 1750-01-01 is excluded from Pre-Colonial (its exclusive end) but included
+        // in Colonial (its inclusive start). Transitional (1700..1800) also covers it.
+        expect(names({ year: 1750, month: 1, day: 1 })).toEqual(['Colonial', 'Transitional']);
+    });
+
+    it('returns all overlapping eras at once', () => {
+        // 1740 is in both Pre-Colonial (< 1750) and Transitional (1700..1800).
+        expect(names({ year: 1740, month: 1, day: 1 })).toEqual(['Pre-Colonial', 'Transitional']);
+        // 1760 is in both Colonial (>= 1750) and Transitional.
+        expect(names({ year: 1760, month: 1, day: 1 })).toEqual(['Colonial', 'Transitional']);
+    });
+
+    it('returns nothing when no era covers the date', () => {
+        const bounded: Era[] = [{ name: 'Only', start: '1000-01-01', end: '1100-01-01', calendar: 'Julian' }];
+        expect(findEras({ year: 1200, month: 1, day: 1 }, bounded)).toEqual([]);
+    });
+});
diff --git a/src/utils/eras.ts b/src/utils/eras.ts
new file mode 100644
index 0000000..3108618
--- /dev/null
+++ b/src/utils/eras.ts
@@ -0,0 +1,20 @@
+// Era matching: which eras cover a given date.
+
+import type { Era, HistoricalDate } from '../types';
+import { gregorianToJulianDay, parseGregorianDate } from './julianDay';
+
+/**
+ * All eras whose date range covers `date`, using the half-open interval
+ * [start, end): a null start is the open past, a null end is the open present.
+ *
+ * Eras MAY overlap in time (parallel calendars coexisting at the same place and
+ * time), so this returns every match, not the first one (see plan.md, Q4).
+ */
+export function findEras(date: HistoricalDate, eras: Era[]): Era[] {
+    const t = gregorianToJulianDay(date);
+    return eras.filter((era) => {
+        const afterStart = era.start === null || gregorianToJulianDay(parseGregorianDate(era.start)) <= t;
+        const beforeEnd = era.end === null || t < gregorianToJulianDay(parseGregorianDate(era.end));
+        return afterStart && beforeEnd;
+    });
+}
diff --git a/src/utils/geo.test.ts b/src/utils/geo.test.ts
new file mode 100644
index 0000000..5298674
--- /dev/null
+++ b/src/utils/geo.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect } from 'vitest';
+import { pointInPolygon, findRegions } from './geo';
+import type { CalendarRegion, Geometry } from '../types';
+
+// A 10×10 square from (0,0) to (10,10) in [lng, lat].
+const square: Geometry = {
+    type: 'Polygon',
+    coordinates: [
+        [
+            [0, 0],
+            [10, 0],
+            [10, 10],
+            [0, 10],
+            [0, 0],
+        ],
+    ],
+};
+
+// Same square with a 2..8 hole punched in the middle.
+const squareWithHole: Geometry = {
+    type: 'Polygon',
+    coordinates: [
+        [
+            [0, 0],
+            [10, 0],
+            [10, 10],
+            [0, 10],
+            [0, 0],
+        ],
+        [
+            [2, 2],
+            [8, 2],
+            [8, 8],
+            [2, 8],
+            [2, 2],
+        ],
+    ],
+};
+
+describe('pointInPolygon', () => {
+    it('detects a point inside the polygon', () => {
+        // pointInPolygon takes (lat, lng); the square spans lat/lng 0..10.
+        expect(pointInPolygon(5, 5, square)).toBe(true);
+    });
+
+    it('detects a point outside the polygon', () => {
+        expect(pointInPolygon(15, 5, square)).toBe(false);
+        expect(pointInPolygon(5, -1, square)).toBe(false);
+    });
+
+    it('treats a point inside a hole as outside', () => {
+        expect(pointInPolygon(5, 5, squareWithHole)).toBe(false);
+        expect(pointInPolygon(1, 1, squareWithHole)).toBe(true); // in the ring, outside the hole
+    });
+
+    it('handles MultiPolygon (inside any part)', () => {
+        const multi: Geometry = {
+            type: 'MultiPolygon',
+            coordinates: [
+                square.type === 'Polygon' ? square.coordinates : [],
+                [
+                    [
+                        [20, 20],
+                        [30, 20],
+                        [30, 30],
+                        [20, 30],
+                        [20, 20],
+                    ],
+                ],
+            ],
+        };
+        expect(pointInPolygon(5, 5, multi)).toBe(true);
+        expect(pointInPolygon(25, 25, multi)).toBe(true);
+        expect(pointInPolygon(15, 15, multi)).toBe(false);
+    });
+});
+
+describe('findRegions', () => {
+    const overlapping: CalendarRegion[] = [
+        { region: { name: 'A', geo: square }, eras: [] },
+        {
+            region: {
+                name: 'B',
+                geo: {
+                    type: 'Polygon',
+                    coordinates: [
+                        [
+                            [5, 5],
+                            [15, 5],
+                            [15, 15],
+                            [5, 15],
+                            [5, 5],
+                        ],
+                    ],
+                },
+            },
+            eras: [],
+        },
+    ];
+
+    it('returns every region containing the point (overlap)', () => {
+        const hits = findRegions(7, 7, overlapping).map((r) => r.region.name);
+        expect(hits).toEqual(['A', 'B']);
+    });
+
+    it('returns a single region when only one contains the point', () => {
+        expect(findRegions(2, 2, overlapping).map((r) => r.region.name)).toEqual(['A']);
+    });
+
+    it('returns nothing when no region contains the point', () => {
+        expect(findRegions(100, 100, overlapping)).toEqual([]);
+    });
+});
diff --git a/src/utils/geo.ts b/src/utils/geo.ts
new file mode 100644
index 0000000..f348676
--- /dev/null
+++ b/src/utils/geo.ts
@@ -0,0 +1,42 @@
+// Point-in-region geometry. GeoJSON coordinates are [lng, lat].
+
+import type { CalendarRegion, Geometry, LinearRing } from '../types';
+
+// Standard ray-casting test against a single ring (x = lng, y = lat).
+function pointInRing(lng: number, lat: number, ring: LinearRing): boolean {
+    let inside = false;
+    for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
+        const [xi, yi] = ring[i];
+        const [xj, yj] = ring[j];
+        const intersects =
+            yi > lat !== yj > lat &&
+            lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
+        if (intersects) inside = !inside;
+    }
+    return inside;
+}
+
+// A polygon is its outer ring (index 0) minus any holes (further rings).
+function pointInPolygonRings(lng: number, lat: number, rings: LinearRing[]): boolean {
+    if (rings.length === 0) return false;
+    const [outer, ...holes] = rings;
+    if (!pointInRing(lng, lat, outer)) return false;
+    return !holes.some((hole) => pointInRing(lng, lat, hole));
+}
+
+/** Whether the point (lat, lng) lies inside a Polygon or MultiPolygon. */
+export function pointInPolygon(lat: number, lng: number, geometry: Geometry): boolean {
+    if (geometry.type === 'Polygon') {
+        return pointInPolygonRings(lng, lat, geometry.coordinates);
+    }
+    return geometry.coordinates.some((polygon) => pointInPolygonRings(lng, lat, polygon));
+}
+
+/** All regions whose geometry contains the point — regions may overlap. */
+export function findRegions(
+    lat: number,
+    lng: number,
+    regions: CalendarRegion[],
+): CalendarRegion[] {
+    return regions.filter((r) => pointInPolygon(lat, lng, r.region.geo));
+}
diff --git a/src/utils/julianDay.test.ts b/src/utils/julianDay.test.ts
new file mode 100644
index 0000000..9385b0a
--- /dev/null
+++ b/src/utils/julianDay.test.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from 'vitest';
+import {
+    gregorianToJulianDay,
+    julianDayToGregorian,
+    julianDayToJulian,
+    parseGregorianDate,
+} from './julianDay';
+
+describe('gregorianToJulianDay', () => {
+    it('matches the J2000 epoch (2000-01-01 = JDN 2451545)', () => {
+        expect(gregorianToJulianDay({ year: 2000, month: 1, day: 1 })).toBe(2451545);
+    });
+
+    it('orders dates monotonically across the BCE/CE boundary', () => {
+        const bce = gregorianToJulianDay({ year: -1, month: 12, day: 31 }); // 2 BCE
+        const ce = gregorianToJulianDay({ year: 1, month: 1, day: 1 }); // 1 CE
+        expect(bce).toBeLessThan(ce);
+    });
+});
+
+describe('julianDayToGregorian', () => {
+    it('round-trips with the forward conversion', () => {
+        for (const date of [
+            { year: 2000, month: 1, day: 1 },
+            { year: 1582, month: 10, day: 15 },
+            { year: 1, month: 1, day: 1 },
+            { year: -44, month: 3, day: 15 },
+        ]) {
+            expect(julianDayToGregorian(gregorianToJulianDay(date))).toEqual(date);
+        }
+    });
+});
+
+describe('julianDayToJulian', () => {
+    it('2000-01-01 Gregorian is 1999-12-19 in the Julian calendar (13-day gap)', () => {
+        const jdn = gregorianToJulianDay({ year: 2000, month: 1, day: 1 });
+        expect(julianDayToJulian(jdn)).toEqual({ year: 1999, month: 12, day: 19 });
+    });
+
+    it('1582-10-15 Gregorian is 1582-10-05 Julian (the 10-day reform gap)', () => {
+        const jdn = gregorianToJulianDay({ year: 1582, month: 10, day: 15 });
+        expect(julianDayToJulian(jdn)).toEqual({ year: 1582, month: 10, day: 5 });
+    });
+});
+
+describe('parseGregorianDate', () => {
+    it('parses a normal date', () => {
+        expect(parseGregorianDate('1750-01-01')).toEqual({ year: 1750, month: 1, day: 1 });
+    });
+
+    it('parses a negative (BCE) year', () => {
+        expect(parseGregorianDate('-0044-03-15')).toEqual({ year: -44, month: 3, day: 15 });
+    });
+
+    it('throws on a malformed string', () => {
+        expect(() => parseGregorianDate('not-a-date')).toThrow();
+    });
+});
diff --git a/src/utils/julianDay.ts b/src/utils/julianDay.ts
new file mode 100644
index 0000000..c8dec59
--- /dev/null
+++ b/src/utils/julianDay.ts
@@ -0,0 +1,72 @@
+// Julian Day Number (JDN) math.
+//
+// The engine compares dates by converting them to a single integer ordering
+// key — the Julian Day Number — so proleptic-Gregorian, Julian, BCE and
+// pre-1582 dates all compare correctly (see plan.md, Q2). All dates here use
+// astronomical year numbering: year 0 = 1 BCE, year -1 = 2 BCE, etc.
+
+import type { HistoricalDate } from '../types';
+
+/** Proleptic-Gregorian date → Julian Day Number (Fliegel & Van Flandern). */
+export function gregorianToJulianDay({ year, month, day }: HistoricalDate): number {
+    const a = Math.floor((14 - month) / 12);
+    const y = year + 4800 - a;
+    const m = month + 12 * a - 3;
+    return (
+        day +
+        Math.floor((153 * m + 2) / 5) +
+        365 * y +
+        Math.floor(y / 4) -
+        Math.floor(y / 100) +
+        Math.floor(y / 400) -
+        32045
+    );
+}
+
+// Richards' algorithm (Explanatory Supplement to the Astronomical Almanac).
+// The Gregorian and Julian variants differ only in how `f` is derived.
+function fromJulianDay(jdn: number, calendar: 'gregorian' | 'julian'): HistoricalDate {
+    const Y = 4716;
+    const J = 1401;
+    const M0 = 2;
+    const N = 12;
+    const R = 4;
+    const P = 1461;
+    const V = 3;
+    const U = 5;
+    const S = 153;
+    const W = 2;
+    const B = 274277;
+    const C = -38;
+
+    let f = jdn + J;
+    if (calendar === 'gregorian') {
+        f += Math.floor((Math.floor((4 * jdn + B) / 146097) * 3) / 4) + C;
+    }
+    const e = R * f + V;
+    const g = Math.floor((e % P) / R);
+    const h = U * g + W;
+    const day = Math.floor((h % S) / U) + 1;
+    const month = ((Math.floor(h / S) + M0) % N) + 1;
+    const year = Math.floor(e / P) - Y + Math.floor((N + M0 - month) / N);
+    return { year, month, day };
+}
+
+/** Julian Day Number → proleptic-Gregorian date. */
+export function julianDayToGregorian(jdn: number): HistoricalDate {
+    return fromJulianDay(jdn, 'gregorian');
+}
+
+/** Julian Day Number → Julian-calendar date. */
+export function julianDayToJulian(jdn: number): HistoricalDate {
+    return fromJulianDay(jdn, 'julian');
+}
+
+const ISO_DATE = /^(-?\d+)-(\d{2})-(\d{2})$/;
+
+/** Parse a proleptic-Gregorian "YYYY-MM-DD" (optionally a negative year). */
+export function parseGregorianDate(value: string): HistoricalDate {
+    const match = ISO_DATE.exec(value);
+    if (!match) throw new Error(`Invalid date "${value}" (expected YYYY-MM-DD)`);
+    return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
+}
diff --git a/src/utils/resolve.test.ts b/src/utils/resolve.test.ts
new file mode 100644
index 0000000..a5c1a95
--- /dev/null
+++ b/src/utils/resolve.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest';
+import { resolve } from './resolve';
+import type { CalendarRegion, Geometry } from '../types';
+
+const square: Geometry = {
+    type: 'Polygon',
+    coordinates: [
+        [
+            [0, 0],
+            [10, 0],
+            [10, 10],
+            [0, 10],
+            [0, 0],
+        ],
+    ],
+};
+
+const regions: CalendarRegion[] = [
+    {
+        region: { name: 'Testland', geo: square },
+        eras: [
+            { name: 'Modern', start: '1750-01-01', end: null, calendar: 'Gregorian', usedBy: 'settlers' },
+            { name: 'Old Ways', start: null, end: '1750-01-01', calendar: 'Algonquian lunar calendar', usedBy: 'locals' },
+        ],
+    },
+];
+
+describe('resolve', () => {
+    it('returns no-region when the point is outside every region', () => {
+        expect(resolve(50, 50, { year: 1700, month: 1, day: 1 }, regions)).toEqual({
+            answers: [],
+            missReason: 'no-region',
+        });
+    });
+
+    it('returns no-era when a region matches but no era covers the date', () => {
+        const bounded: CalendarRegion[] = [
+            { region: { name: 'X', geo: square }, eras: [{ name: 'E', start: '1000-01-01', end: '1100-01-01', calendar: 'Julian' }] },
+        ];
+        expect(resolve(5, 5, { year: 1700, month: 1, day: 1 }, bounded)).toEqual({
+            answers: [],
+            missReason: 'no-era',
+        });
+    });
+
+    it('returns the matching answer with conversion', () => {
+        const { answers, missReason } = resolve(5, 5, { year: 1900, month: 1, day: 1 }, regions);
+        expect(missReason).toBeUndefined();
+        expect(answers).toHaveLength(1);
+        expect(answers[0].era.name).toBe('Modern');
+        expect(answers[0].conversion).toMatchObject({ system: 'Gregorian', localYearLabel: '1900', convertible: true });
+    });
+
+    it('gives the descriptive-only answer before the boundary', () => {
+        const { answers } = resolve(5, 5, { year: 1700, month: 1, day: 1 }, regions);
+        expect(answers).toHaveLength(1);
+        expect(answers[0].era.usedBy).toBe('locals');
+        expect(answers[0].conversion.convertible).toBe(false);
+    });
+});
diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts
new file mode 100644
index 0000000..16fd529
--- /dev/null
+++ b/src/utils/resolve.ts
@@ -0,0 +1,30 @@
+// The engine: "what year is it?" at a point and date.
+//
+// Composes the layers into the plural-answers model — every valid reply a local
+// could give. Pure (regions are passed in) so it is easy to unit-test; the UI
+// supplies the loaded calendar data.
+
+import type { CalendarRegion, HistoricalDate, Answer, Resolution } from '../types';
+import { findRegions } from './geo';
+import { findEras } from './eras';
+import { convert } from './calendars';
+
+export function resolve(
+    lat: number,
+    lng: number,
+    date: HistoricalDate,
+    regions: CalendarRegion[],
+): Resolution {
+    const matched = findRegions(lat, lng, regions);
+    if (matched.length === 0) return { answers: [], missReason: 'no-region' };
+
+    const answers: Answer[] = [];
+    for (const { region, eras } of matched) {
+        for (const era of findEras(date, eras)) {
+            answers.push({ region, era, conversion: convert(date, era.calendar) });
+        }
+    }
+
+    if (answers.length === 0) return { answers: [], missReason: 'no-era' };
+    return { answers };
+}