fuzzyworld

when-am-i / commit

docs: add plan.md with the design and phased roadmap

d96c425a66d511b25a6ce8ee5c2108b5e45db473 · Pavel Safronov · 2026-06-16 23:32:22 -0700 · parents a167bf48a89e

docs: add plan.md with the design and phased roadmap

Captures the resolved decisions (plural answers, Temporal + hand-rolled
Julian, proleptic-Gregorian + Julian Day Number comparison, descriptive-only
calendars, per-calendar PR screenshots) and the phased execution plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 plan.md | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 238 insertions(+)

diff --git a/plan.md b/plan.md
new file mode 100644
index 0000000..595d9d9
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,238 @@
+# Plan — WhatYearIsIt
+
+Status as of 2026-06-16. Owner: Pavel.
+
+## Goal of this milestone
+
+Ship an **end-to-end MVP**: a user picks a location on the map and a **full date**
+(year + month + day, BCE/CE), and the app resolves the matching region and era and
+displays the **local calendar year** using **real calendar math** (not just the era's
+metadata). Build the engine with **Vitest tests** and add **ESLint**.
+
+Boston is the proof-of-architecture region; the loop must generalize to any region file.
+
+---
+
+## Where we are today (baseline)
+
+Working:
+- [App.tsx](src/App.tsx): header + intro + map; echoes clicked lat/lng.
+- [src/components/MapPicker.tsx](src/components/MapPicker.tsx): the live map (`center=[20,0]`, `zoom=2`).
+- Data: one region file, [boston.json](src/data/calendars/boston.json); schema docs in
+  [SCHEMA.json](src/data/calendars/SCHEMA.json) / [SCHEMA.md](src/data/calendars/SCHEMA.md).
+- Deploy: [.gitlab-ci.yml](.gitlab-ci.yml) builds and publishes `dist/` to GitLab Pages.
+
+Missing / debt:
+- **No date picker.**
+- **No resolution engine**: nothing loads the calendar JSON, runs point-in-polygon,
+  matches an era, or converts/displays a local year.
+- **No build-time data validation** (README claims it exists — it does not).
+- **Dead code**: [src/MapPicker.tsx](src/MapPicker.tsx) is an unused, broken duplicate
+  (no `center`/`zoom`) of the real component.
+- **Empty stubs**: [src/data/index.ts](src/data/index.ts), [src/hooks/index.ts](src/hooks/index.ts),
+  [src/types/index.ts](src/types/index.ts), [src/utils/index.ts](src/utils/index.ts).
+- **No tests, no lint.**
+
+---
+
+## Architecture for the MVP
+
+Core framing (from Q4): *"What year is it?"* is addressed to **a person**, so the answer
+is **plural**. The engine does not pick one era — it **collects every valid answer** a local
+could give. Ask a Boston settler and an Algonquian person in 1700 and you get two answers;
+both are valid and we show both.
+
+Data flow: `click (lat,lng)` + `date` → **find all region(s)** containing the point →
+within each, **find all eras** whose date range covers the date (eras may overlap) →
+**convert** the date into each era's calendar (or fall back to descriptive-only) →
+**render the full list of answers**.
+
+### 1. Types — [src/types/index.ts](src/types/index.ts)
+Mirror the JSON schema and add result types:
+- `Geometry` (GeoJSON Polygon/MultiPolygon; coords are `[lng, lat]`).
+- `Region { name; geo: Geometry }`.
+- `Era { name; start; end; calendar; usedBy?; description?; sources? }` — `start`/`end` are
+  `YYYY-MM-DD | null`; new optional **`usedBy`** labels *whose* answer it is
+  (e.g. "British colonists", "Algonquian peoples"), which the plural model needs.
+- `CalendarRegion { region: Region; eras: Era[] }`.
+- `HistoricalDate` — see "Date representation" below.
+- `Answer { region: Region; era: Era; conversion: CalendarConversion }` — one possible
+  reply a local could give.
+- `Resolution { answers: Answer[]; missReason?: 'no-region' | 'no-era' }` — the engine
+  returns the **list** of answers (possibly empty, with a reason).
+- `CalendarConversion { system: string; localYearLabel: string; localDateLabel?: string; convertible: boolean }`
+  — `convertible: false` ⇒ descriptive-only (see §5).
+
+### 2. Data loading — [src/data/index.ts](src/data/index.ts)
+- Load all region files with `import.meta.glob('./calendars/*.json', { eager: true })`,
+  excluding `SCHEMA.json`.
+- Export a typed `regions: CalendarRegion[]`.
+- Runtime shape-guard so a malformed file fails loudly (complements build-time validation).
+
+### 3. Geometry — `src/utils/geo.ts`
+- `pointInPolygon(lat, lng, geometry): boolean` — ray-casting, supports `Polygon` and
+  `MultiPolygon`, respects GeoJSON `[lng, lat]` ordering and ring holes.
+- `findRegions(lat, lng, regions): CalendarRegion[]` — return **all** matches (regions
+  can overlap; UI must handle 0, 1, or many).
+
+### 4. Era matching — `src/utils/eras.ts`
+- `findEras(date, eras): Era[]` — return **all** eras whose half-open interval `[start, end)`
+  covers the date (`null` start = open past, `null` end = open present). **Eras may overlap**
+  (Q4): parallel calendars coexisting at the same place and time is expected, not an error.
+- Compare via Julian Day Number (see Date representation) so BCE/CE and proleptic-Gregorian
+  boundaries compare correctly.
+
+### 5. Calendar conversion — `src/utils/calendars.ts`
+- `convert(date, calendarName): CalendarConversion`.
+- Registry keyed by the era's `calendar` string. Two independent gates (Q3):
+  - **Acceptance** of a calendar into the app is a *data* decision (your rule: a wiki page
+    with enough sourced info). Not enforced here.
+  - **Convertibility** is whether a deterministic epoch+rule exists to compute a numeric
+    local year. Observational calendars with no continuous count (e.g. "Algonquian lunar
+    calendar") are **descriptive-only**: return `convertible: false`, still show the era,
+    its name/description/sources and the Gregorian year for reference, with a clear
+    "no continuous numeric year to convert to" note — **never silently mislabel**.
+- Convertible set (Q1): Gregorian + hand-rolled Julian, plus `@js-temporal/polyfill`
+  systems (Islamic, Hebrew, Persian, Japanese, Chinese, …).
+- **MVP scope for descriptive-only** (decided): render `description` + `sources` links
+  (e.g. a Wikipedia explanation) + Gregorian reference year. **No extra per-calendar data
+  needed** — the schema already has `sources`. The rich answer (moon name + phase +
+  "winters since event") is a **stretch goal**, see below.
+
+### 6. UI
+- `src/components/DatePicker.tsx`: year + month + day inputs + BCE/CE toggle; emits a
+  `HistoricalDate`. Validate ranges (days-in-month, no year 0 in historical numbering).
+- `src/components/ResultPanel.tsx`: renders **the list of answers** — for each, *whose*
+  answer (`usedBy`), the calendar system, the local year/date (or descriptive-only note),
+  description, and sources. Framing: "Ask a British colonist → …; ask an Algonquian
+  person → …". Plus miss states (no region / no era covering the date).
+- [App.tsx](src/App.tsx): hold `{ lat, lng }` + `HistoricalDate`, call the engine, render the panel.
+
+### 7. Cleanup
+- Delete [src/MapPicker.tsx](src/MapPicker.tsx) (dead duplicate).
+- Replace empty `index.ts` stubs with real exports or delete the unused ones.
+
+---
+
+## Data pipeline (lightweight, to make the README honest)
+
+- Promote [SCHEMA.json](src/data/calendars/SCHEMA.json) to a real **JSON Schema** (or a
+  zod schema) and add `npm run validate:data` that checks every region file:
+  required fields, valid geometry, parseable proleptic-Gregorian dates. **Overlapping eras
+  are allowed** (Q4) — do not flag them.
+- Add the optional `usedBy` field to the schema (back-compatible).
+- Update [boston.json](src/data/calendars/boston.json) to reflect **coexistence**: around
+  colonization the colonial Gregorian era and the Algonquian lunar era **overlap in time**
+  rather than a clean 1750 cutover, each tagged with `usedBy`.
+- Wire validation into `prebuild` and into CI so bad data can't deploy.
+
+---
+
+## Verify tooling (adopt from `~/code/share`)
+
+Reuse Pavel's established pattern (reference: `~/code/share/web/scripts/*.mts`):
+- A `.mts` Playwright script run via `tsx` that **auto-starts the Vite dev server**,
+  forces deterministic app state via **URL query params** (here: a `?demo=` that pre-sets
+  picked location + date so the answers panel renders without manual clicking), stabilizes
+  any volatile content, screenshots to `screenshots/<category>/`, and **checks them in**.
+- ImageMagick `magick` composites for documentation imagery.
+- Most valuable once the ResultPanel exists → schedule in **Phase 4** (UI). The `?demo=`
+  state hook should be designed into App during Phase 4, not bolted on later.
+
+### Per-calendar PR screenshots
+A `screenshot:calendar -- <file.json>` script that renders **one calendar's** answers so a
+**calendar PR can include illustrative screenshots showing any major issues**:
+- Compute a point **inside the region** (polygon centroid / point-on-surface) and force the
+  app there via `?demo=` with a **representative date per era** (e.g. midpoint of each era's
+  range). Screenshot the answers panel for each.
+- Optionally a single composite per calendar (map + answers) via `magick`.
+- This makes review concrete: a wrong region polygon, an off-by-a-century era boundary, a
+  miscategorized "convertible vs descriptive-only", or a broken conversion all show up
+  **visually** in the PR. Reviewer (Pavel) checks the screenshot against Wikipedia/facts.
+- Document this in the contributor section so each calendar PR attaches its screenshots.
+  Depends on the `?demo=` hook + ResultPanel → **Phase 4+**.
+
+## Testing & lint
+
+- **Vitest** + config; unit tests for: `pointInPolygon` (inside/outside/edge/MultiPolygon),
+  `findEras` (boundaries, open intervals, **overlapping eras**, BCE), `convert` (known
+  date↔year pairs per system, descriptive-only fallback), and a data-validation test that
+  loads every region file.
+- **ESLint** (flat config, TS + react-hooks) + `npm run lint`.
+- Add `test`, `lint`, `validate:data` scripts to [package.json](package.json).
+- Update [.gitlab-ci.yml](.gitlab-ci.yml): run `lint` + `test` before `build`.
+
+---
+
+## Documentation gate (required before "done")
+
+- README: document the date picker, the resolution engine, the plural-answers model, the
+  list of **convertible vs descriptive-only** calendars, the new scripts (`test`, `lint`,
+  `validate:data`), and the real validation step (replacing the current aspirational claim).
+- Keep [SCHEMA.md](src/data/calendars/SCHEMA.md) in sync with the promoted JSON Schema.
+- Note any data-format changes back into the contributor section.
+
+---
+
+## Phased execution
+
+1. **Foundations** — types, data loader, delete dead `MapPicker`, Vitest + ESLint scaffolding, CI.
+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.
+
+---
+
+## Stretch goals (post-MVP)
+
+### 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
+**Massachusett person**:
+
+> "We don't count years that way — but it is the **[early-spring] moon**, **~3 days after a
+> new moon**, and about **N winters since [a remembered event]**."
+
+This is authentic *in structure*. Its pieces split sharply by cost:
+
+- **Computable for free (no per-region data):**
+  - **Lunar phase / days since new moon** — deterministic astronomy for any date. Could
+    even ship earlier than the rest.
+  - **Eclipse-anchored "winters since"** — if the reference event is an eclipse, the date
+    is computable.
+- **Needs curated, sourced per-region data (the expensive part — hence stretch):**
+  - **Moon names** — vary by nation, inconsistently attested; must be cited, not invented.
+  - **Historical-event anchors for "winters since"** — must be real, dateable, locally
+    remembered events (e.g. the **1616–1619 epidemic**), **not** invented ones. The
+    "asteroid crash" in the brainstorm is illustrative only — no such event occurred.
+- **Schema (when we do this):** add optional `reckoning` text and an optional `moons`
+  list / `anchorEvents` to descriptive-only calendars. Optional + back-compatible.
+
+### Data accuracy
+- Refine Boston's generic `"Algonquian lunar calendar"` to the specific **Massachusett**
+  people (via `usedBy`), with sources. "Algonquian" is a language family, not one calendar.
+
+---
+
+## Resolved decisions
+
+- **Q1 — Conversion library.** ✅ `@js-temporal/polyfill` for library-backed systems
+  (Islamic, Hebrew, Persian, Japanese, Chinese, …) **plus a hand-rolled Julian↔Gregorian**
+  (Temporal has no Julian calendar).
+- **Q2 — Historical date representation.** ✅ JSON `start`/`end` are **proleptic Gregorian**
+  (rule written into SCHEMA.md). Internally normalize to a **Julian Day Number** for all
+  comparisons so BCE/CE and pre-1582 dates sort correctly. **UTC is not involved** — it is a
+  time-of-day/timezone standard, not a calendar; we resolve dates, not clock time, and avoid
+  JS `Date` in the engine (it mishandles BCE and drags in timezones).
+- **Q3 — Descriptive-only calendars.** ✅ Two gates: *acceptance* (data decision: a sourced
+  wiki page is enough) vs *convertibility* (a deterministic epoch+rule exists). Calendars
+  that pass acceptance but not convertibility (e.g. Algonquian lunar) are shown with their
+  name/description/sources + the Gregorian year for reference and a clear "no continuous
+  numeric year to convert to" note. Never hidden, never mislabeled. (See "Algonquian
+  answers" below for the nuance the UI should convey.)
+- **Q4 — Plural answers / overlap.** ✅ The engine returns **all** valid answers.
+  Geographic region overlap → show all matched regions. Eras within a region **may overlap
+  in time** → show all concurrent calendars, because "what year is it?" is asked of a person
+  and different people legitimately answer differently.