Plan — When am I? (formerly "What Year Is It?")
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: header + intro + map; echoes clicked lat/lng.
- src/components/MapPicker.tsx: the live map (center=[20,0], zoom=2).
- Data: region files massachusett.json
(Massachusett reckoning) and colonial-america.json
(empire-wide Julian/Gregorian); schema docs in
SCHEMA.json / SCHEMA.md.
- Deploy: .fw-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).~~
Resolved 2026-06-20: npm run validate:data
(scripts/validate-data.mts) now validates every
region file and runs in prebuild + CI, so the README claim is true. See the
data-pipeline note below.
- Dead code: src/MapPicker.tsx is an unused, broken duplicate
(no center/zoom) of the real component.
- Empty stubs: src/data/index.ts, src/hooks/index.ts,
src/types/index.ts, src/utils/index.ts.
- No tests, no lint.
Architecture for the MVP
Core framing (from Q4): "When am I?" 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
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
- Load all region files with
import.meta.glob('./calendars/*.json', { eager: true }), excludingSCHEMA.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, supportsPolygonandMultiPolygon, 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 (nullstart = open past,nullend = 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
calendarstring. 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/polyfillsystems (Islamic, Hebrew, Persian, Japanese, Chinese, …). - MVP scope for descriptive-only (decided): render
description+sourceslinks (e.g. a Wikipedia explanation) + Gregorian reference year. No extra per-calendar data needed — the schema already hassources. The rich answer's lunar phase has since shipped (computed, no data needed); the remaining pieces (moon name + "winters since event") are still a stretch goal, see below.
6. UI
src/components/DatePicker.tsx: year + month + day inputs + BCE/CE toggle; emits aHistoricalDate. 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: hold
{ lat, lng }+HistoricalDate, call the engine, render the panel.
7. Cleanup
- Delete src/MapPicker.tsx (dead duplicate).
- Replace empty
index.tsstubs with real exports or delete the unused ones.
Data pipeline (lightweight, to make the README honest)
Shipped 2026-06-20 as scripts/validate-data.mts
(npm run validate:data), wired into prebuild and CI.
Decision: rather than promote SCHEMA.json
to a real JSON Schema / zod schema, the validator itself is the authoritative,
machine-checkable schema. A hand-rolled validator covers semantic checks a plain
JSON Schema cannot express — dates that actually parse, geometry rings that are
closed with in-range [lng, lat], and [start, end) ordering — and avoids a
second spec that could drift from the validator. SCHEMA.json / SCHEMA.md remain
the human-readable field docs and now point at the validator. Overlapping eras
are intentionally not flagged (Q4).
- ~~Promote SCHEMA.json to a real JSON Schema (or a
zod schema) and~~ add
npm run validate:datathat checks every region file: required fields, valid geometry, parseable proleptic-Gregorian dates. Overlapping eras are allowed (Q4) — do not flag them. - Add the optional
usedByfield to the schema (back-compatible). - Model coexistence: around colonization the colonial calendars and the Massachusett
lunar reckoning apply at the same place and time, each tagged with
usedBy. (Now split across two overlapping regions — massachusett.json and colonial-america.json — rather than one Boston file.) - Wire validation into
prebuildand 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:datascripts to package.json. - Update .fw-ci.yml: run
lint+testbeforebuild.
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 in sync with the promoted JSON Schema.
- Note any data-format changes back into the contributor section.
Phased execution
- Foundations — types, data loader, delete dead
MapPicker, Vitest + ESLint scaffolding, CI. - Engine — geo + era matching + conversion registry, with tests. Gregorian/Julian first.
- Library-backed calendars — wire the chosen conversion lib (Q1), add tests.
- UI — DatePicker + ResultPanel + App wiring; handle all miss states.
- Data pipeline — ✅
validate:data+prebuild/CI gate (see note above; the validator is the schema, no separate JSON Schema file). - Docs — README + SCHEMA sync. ✅ Generalization proven 2026-06-20 with three
region files beyond Boston:
iran.json (Solar Hijri + lunar Hijri, exercising the
Temporal-backed Persian and Islamic converters),
russian-america.json (a second Julian→Gregorian
cutover, at the 1867 Alaska transfer), and
oregon-country.json (Hudson's Bay Company,
covering the Seattle area). Note: the Temporal
chinesecalendar 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.
The five most-populous calendars added 2026-06-20 (covering the most
humans × time, per Pavel's request): china.json
(Chinese sexagenary cycle — descriptive-only, hand-rolled stem-branch/zodiac
naming, since the Temporal lunisolar chinese is broken),
india.json (Saka, existing Temporal converter),
japan.json (a pre-1873 lunisolar
descriptive era + the modern Japanese nengō era, Temporal japanese using
era/eraYear, scoped to Meiji+),
thailand.json (Buddhist Era, existing
converter), and ethiopia.json (Ethiopian
calendar, hand-rolled via JDN because the Temporal ethiopic and
coptic calendars also throw in this polyfill — same class of bug as
chinese). New converters live in
src/utils/calendars.ts with tests.
Stretch goals (post-MVP)
AI-assisted dataset authoring (requested 2026-06-20)
✅ Shipped 2026-06-20 as the new-calendar skill
(.claude/skills/new-calendar/SKILL.md):
it walks an author through researching a place/people's calendar(s) with sourced
citations, drafting the GeoJSON polygon and eras, picking the right converter (or
descriptive-only), and running validate:data / gen:badges /
screenshot:calendar so the result is PR-ready — with the project's "never
invent, always source" rule front and centre. A review/QA skill remains a
possible follow-up. Original sketch of what the skill does:
- 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
(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.
In-browser map creation/editing (requested 2026-06-20)
Let contributors draw and edit region polygons directly in the app, instead of
hand-editing GeoJSON or round-tripping through geojson.io. Lowers the contribution bar
a lot (the current flow asks people to author raw [lng, lat] rings by hand). Initially
felt heavy-handed (a full path-editing UI), but it now pairs naturally with the other
authoring/build-time tooling being considered:
- A Leaflet drawing layer (e.g. leaflet-draw / react-leaflet editing) to add/move/delete
polygon vertices on the existing map, emitting schema-shaped GeoJSON.
- An export path: produce a ready-to-commit region JSON (or a prefilled GitLab
merge-request / issue) so a drawn map becomes a contribution without local tooling.
- Naturally combines with land-clipping (below) so a roughly-drawn coastal polygon is
auto-trimmed to land, and with AI-assisted authoring (above) so the AI can draft a
polygon the user then nudges by hand.
- Open question: purely a contributor/authoring tool vs. a playful end-user feature
("draw your own region"). Keep authoring output reproducible either way.
Scalable calendar colors (requested 2026-06-20)
✅ Shipped 2026-06-20 in src/utils/calendarColor.ts.
The new calendarColorScale(names) assigns colors to the set of calendars on
screen at once: curated calendars keep their PALETTE color, the rest are walked
in a stable (sorted) order and handed golden-angle-spaced hues so they fan out
around the wheel instead of colliding on a raw name hash. App.tsx
builds one scale from every calendar in the dataset, so a calendar keeps the same
color across the map, the timeline, and as you scrub dates (legend ↔ map ↔ timeline
agree). calendarColor(name) is retained for a lone name / fallback (now also
golden-angle-based). Possible later polish: contrast/accessibility tuning so no
generated color vanishes on the map tiles. Original framing of the problem:
- Space hues apart (e.g. golden-angle increments) instead of a raw name hash, and/or
assign colors per render so the calendars actually on screen are maximally distinct.
- Keep determinism where it matters (legend ↔ map agreement within a view) and keep the
curated palette for the handful of well-known calendars (or drop it if a good generator
makes it redundant — that would touch calendarColor.test.ts).
- Consider contrast/accessibility (avoid colors that vanish on the map tiles).
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 — shipped.
lunarPhaseLabelinsrc/utils/calendars.tscomputes the night-of-the-moon + phase from the Julian Day Number; the "Algonquian lunar calendar" answer now leads with it (stillconvertible: false, no year) instead of the bare "no year count" note. - Eclipse-anchored "winters since" — superseded by the per-region
anchorEventsapproach below (eclipses turned out to need either a heavy ephemeris or the same curated data, so they're just one kind of anchor). - "Winters since" via per-region anchor events — ✅ Shipped 2026-06-20. Pavel's
call (the most reliable option): each anchor must be a documented, sourced
event supplied in the data; the engine only counts the winters, never invents
one. Implemented as an optional
Era.anchorEvents({ name, date, sources }[]), withwintersSince()in src/utils/calendars.ts picking the most recent anchor at/before the date and reporting "about N winters since <event>" (counted as completed calendar years so anniversaries aren't off-by-one). Wired through resolve.ts →CalendarConversion.reckoning→ a sourced line in ResultPanel.tsx; validated invalidate-data.mtsand the data loader; documented in SCHEMA.md/README. massachusett.json carries the first anchor (the 1616–1619 epidemic → "about 84 winters since" in 1700). - Still needs curated, sourced per-region data (the expensive part — hence stretch):
- Moon names — vary by nation, inconsistently attested; must be cited, not invented.
- More historical-event anchors for other descriptive calendars — must be real, dateable, locally remembered events, not invented ones (the "asteroid crash" in the brainstorm was illustrative only — no such event occurred).
- Schema: the
anchorEventspart shipped (above); optionalreckoningtext and amoonslist remain optional + back-compatible future additions.
Data accuracy
- Refine Boston's generic
"Algonquian lunar calendar"to the specific Massachusett people (viausedBy), with sources. "Algonquian" is a language family, not one calendar.
Resolved decisions
- Q1 — Conversion library. ✅
@js-temporal/polyfillfor 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/endare 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 JSDatein 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 "When am I?" is asked of a person and different people legitimately answer differently.
- Q5 — Modern global default. ✅ (implemented 2026-06-20, src/utils/resolve.ts)
The Gregorian calendar is the world's near-universal civil calendar today, so for dates
from 1927 onward (chosen cutoff — just after the last major national adoption, Turkey
1926) the engine appends a synthetic "most of the world" Gregorian answer anywhere on
Earth, unless a region already supplies a Gregorian answer for that date (dedup, no double
Gregorian). So most modern clicks resolve even with no specific region, and a place with a
local calendar shows both (e.g. modern Iran → Persian and Gregorian). It is engine-only:
not a region polygon, so it never shades the map (a world polygon would flood it); a
matching band is mirrored onto the year timeline. A single global cutoff is a deliberate
simplification (adoption was staggered: 1582/1752/1918/1926/1949/2016…), hence "most of
the world", not a universal claim. The cutoff is one constant (
MODERN_DEFAULT_START_YEAR). Open follow-up: the default currently applies over oceans too — could be land-bounded once the land-clipping work (stretch goals) lands.