when-am-i / commit
feat: add calendar type model and validated data loader
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; +}