fuzzyworld

when-am-i / commit

feat: add calendar type model and validated data loader

a167bf48a89ef9b807b6a90249219ed81bdea868 · Pavel Safronov · 2026-06-16 23:32:13 -0700 · parents 9a0109d5a22f

feat: add calendar type model and validated data loader

- src/types/index.ts: the full domain model — Geometry, Region, Era (with the
  new optional `usedBy`), CalendarRegion, HistoricalDate (proleptic-Gregorian,
  astronomical year numbering), and the plural-answer output types
  CalendarConversion (with `convertible`), Answer, and Resolution.
- src/data/index.ts: load every calendars/*.json via import.meta.glob
  (excluding SCHEMA.json) with a runtime shape-guard that throws with the
  offending file path on malformed data.
- src/data/index.test.ts: smoke tests for the loader.

See plan.md for the design (plural answers, descriptive-only calendars, etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 src/data/index.test.ts |  31 +++++++++++++
 src/data/index.ts      |  74 +++++++++++++++++++++++++++++-
 src/types/index.ts     | 121 ++++++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 222 insertions(+), 4 deletions(-)

diff --git a/src/data/index.test.ts b/src/data/index.test.ts
new file mode 100644
index 0000000..679d371
--- /dev/null
+++ b/src/data/index.test.ts
@@ -0,0 +1,31 @@
+import { describe, it, expect } from 'vitest';
+import { regions } from './index';
+
+// 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.
+describe('calendar data loader', () => {
+    it('loads at least one region', () => {
+        expect(regions.length).toBeGreaterThan(0);
+    });
+
+    it('loads Boston with named eras and Polygon geometry', () => {
+        const boston = regions.find((r) => r.region.name === 'Boston Area');
+        expect(boston).toBeDefined();
+        expect(boston!.region.geo.type).toBe('Polygon');
+        expect(boston!.eras.length).toBeGreaterThan(0);
+        for (const era of boston!.eras) {
+            expect(era.name).toBeTruthy();
+            expect(era.calendar).toBeTruthy();
+        }
+    });
+
+    it('every era has string|null start/end and a calendar', () => {
+        for (const { eras } of regions) {
+            for (const era of eras) {
+                expect(['string', 'object']).toContain(typeof era.start); // string or null
+                expect(['string', 'object']).toContain(typeof era.end);
+                expect(typeof era.calendar).toBe('string');
+            }
+        }
+    });
+});
diff --git a/src/data/index.ts b/src/data/index.ts
index fb9303c..f2719f3 100644
--- a/src/data/index.ts
+++ b/src/data/index.ts
@@ -1,2 +1,72 @@
-// Data files will go here
-export { };
+// Loads every calendar region file in ./calendars/ at build time.
+//
+// Files are plain JSON (one CalendarRegion each); see SCHEMA.md. We use Vite's
+// import.meta.glob so the data is bundled and type-checked alongside the app.
+// A runtime shape-guard makes a malformed file fail loudly with its path,
+// complementing the build-time `validate:data` step (see plan.md).
+
+import type {
+    CalendarRegion,
+    Era,
+    Geometry,
+    Region,
+} from '../types';
+
+// Eagerly import all calendar JSON, excluding the schema doc.
+const modules = import.meta.glob<{ default: unknown }>(
+    ['./calendars/*.json', '!./calendars/SCHEMA.json'],
+    { eager: true },
+);
+
+function fail(path: string, msg: string): never {
+    throw new Error(`Invalid calendar data in ${path}: ${msg}`);
+}
+
+function isGeometry(value: unknown): value is Geometry {
+    if (typeof value !== 'object' || value === null) return false;
+    const g = value as { type?: unknown; coordinates?: unknown };
+    return (
+        (g.type === 'Polygon' || g.type === 'MultiPolygon') &&
+        Array.isArray(g.coordinates)
+    );
+}
+
+function assertRegion(value: unknown, path: string): Region {
+    if (typeof value !== 'object' || value === null) fail(path, 'region missing');
+    const r = value as { name?: unknown; geo?: unknown };
+    if (typeof r.name !== 'string') fail(path, 'region.name must be a string');
+    if (!isGeometry(r.geo)) fail(path, 'region.geo must be a Polygon or MultiPolygon');
+    return { name: r.name, geo: r.geo };
+}
+
+function assertEra(value: unknown, path: string, i: number): Era {
+    if (typeof value !== 'object' || value === null) fail(path, `eras[${i}] not an object`);
+    const e = value as Record<string, unknown>;
+    if (typeof e.name !== 'string') fail(path, `eras[${i}].name must be a string`);
+    if (typeof e.calendar !== 'string') fail(path, `eras[${i}].calendar must be a string`);
+    if (e.start !== null && typeof e.start !== 'string') fail(path, `eras[${i}].start must be YYYY-MM-DD or null`);
+    if (e.end !== null && typeof e.end !== 'string') fail(path, `eras[${i}].end must be YYYY-MM-DD or null`);
+    return {
+        name: e.name,
+        start: e.start as string | null,
+        end: e.end as string | null,
+        calendar: e.calendar,
+        usedBy: typeof e.usedBy === 'string' ? e.usedBy : undefined,
+        description: typeof e.description === 'string' ? e.description : undefined,
+        sources: Array.isArray(e.sources) ? (e.sources as string[]) : undefined,
+    };
+}
+
+function assertCalendarRegion(value: unknown, path: string): CalendarRegion {
+    if (typeof value !== 'object' || value === null) fail(path, 'file is not an object');
+    const v = value as { region?: unknown; eras?: unknown };
+    const region = assertRegion(v.region, path);
+    if (!Array.isArray(v.eras)) fail(path, 'eras must be an array');
+    const eras = v.eras.map((era, i) => assertEra(era, path, i));
+    return { region, eras };
+}
+
+/** All calendar regions, validated. Throws on the first malformed file. */
+export const regions: CalendarRegion[] = Object.entries(modules).map(
+    ([path, mod]) => assertCalendarRegion(mod.default, path),
+);
diff --git a/src/types/index.ts b/src/types/index.ts
index e9b2f44..25a4bc7 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,2 +1,119 @@
-// Shared types will go here
-export { };
+// Shared types for WhatYearIsIt.
+//
+// Data flow (see plan.md): click (lat,lng) + date
+//   → find all regions containing the point
+//   → find all eras (may overlap) covering the date
+//   → convert the date into each era's calendar (or fall back to descriptive-only)
+//   → render the full list of Answers.
+
+// ── GeoJSON geometry ────────────────────────────────────────────────────────
+// We only need area geometries for point-in-region tests. GeoJSON coordinates
+// are [longitude, latitude] (note the order — easy to get backwards).
+
+/** A single linear ring: an array of [lng, lat] positions. */
+export type LinearRing = [number, number][];
+
+export interface Polygon {
+    type: 'Polygon';
+    /** First ring is the outer boundary; any further rings are holes. */
+    coordinates: LinearRing[];
+}
+
+export interface MultiPolygon {
+    type: 'MultiPolygon';
+    /** An array of polygons, each itself an array of rings. */
+    coordinates: LinearRing[][];
+}
+
+export type Geometry = Polygon | MultiPolygon;
+
+// ── Region & era (the on-disk JSON shape) ─────────────────────────────────────
+// One JSON file in src/data/calendars/ deserializes to a CalendarRegion.
+
+export interface Region {
+    name: string;
+    geo: Geometry;
+}
+
+/**
+ * One calendar era used in a region over some span of time. Eras within a
+ * region MAY overlap in time — parallel calendars coexisting at the same place
+ * and time is expected (e.g. colonial Gregorian + Algonquian lunar in 1700
+ * Boston), not an error.
+ */
+export interface Era {
+    name: string;
+    /** Proleptic-Gregorian YYYY-MM-DD, or null for "since the earliest time". */
+    start: string | null;
+    /** Proleptic-Gregorian YYYY-MM-DD, or null for "up to the present". */
+    end: string | null;
+    /** Calendar system name, e.g. "Gregorian", "Julian", "Islamic". */
+    calendar: string;
+    /** Whose answer this is, e.g. "British colonists", "Massachusett people". */
+    usedBy?: string;
+    description?: string;
+    /** URLs or citations backing this era. */
+    sources?: string[];
+}
+
+export interface CalendarRegion {
+    region: Region;
+    eras: Era[];
+}
+
+// ── Engine input ──────────────────────────────────────────────────────────────
+
+/**
+ * A date in the proleptic Gregorian calendar using astronomical year numbering:
+ * year 0 = 1 BCE, year -1 = 2 BCE, etc. This is the engine-internal form; the
+ * UI translates a BCE/CE picker into it. Comparisons go through a Julian Day
+ * Number so BCE/CE and pre-1582 dates order correctly (see plan.md, Q2).
+ */
+export interface HistoricalDate {
+    year: number;
+    /** 1–12. */
+    month: number;
+    /** 1–31. */
+    day: number;
+}
+
+// ── Engine output ─────────────────────────────────────────────────────────────
+
+/** The result of converting a date into one calendar system. */
+export interface CalendarConversion {
+    /** The calendar system name (echoes Era.calendar). */
+    system: string;
+    /** Human-readable local year, e.g. "1700 AD", "AH 1111", "5460". */
+    localYearLabel: string;
+    /** Optional fuller local date when meaningful. */
+    localDateLabel?: string;
+    /**
+     * Whether a deterministic epoch+rule produced a numeric local year.
+     * `false` ⇒ descriptive-only: no continuous year count exists (e.g. an
+     * observational lunar calendar). The UI then shows description + sources +
+     * the Gregorian reference year instead of a converted number.
+     */
+    convertible: boolean;
+    /** Optional clarifying note, esp. for descriptive-only calendars. */
+    note?: string;
+}
+
+/** One possible reply a local could give to "what year is it?". */
+export interface Answer {
+    region: Region;
+    era: Era;
+    conversion: CalendarConversion;
+}
+
+/** Why the engine produced no answers. */
+export type MissReason = 'no-region' | 'no-era';
+
+/**
+ * The engine's full result: every valid answer a local could give. `answers`
+ * may be empty, in which case `missReason` explains why (no region covers the
+ * point, or a region matched but no era covers the date).
+ */
+export interface Resolution {
+    answers: Answer[];
+    missReason?: MissReason;
+}