when-am-i / commit
feat: per-region "winters since" anchor events
scripts/validate-data.mts | 24 +++++++++++++++++++ src/components/ResultPanel.tsx | 24 +++++++++++++++++++ src/data/calendars/SCHEMA.md | 14 ++++++++++- src/data/calendars/massachusett.json | 9 ++++++++ src/data/index.ts | 17 ++++++++++++++ src/types/index.ts | 28 ++++++++++++++++++++++ src/utils/calendars.test.ts | 33 +++++++++++++++++++++++++- src/utils/calendars.ts | 45 ++++++++++++++++++++++++++++++++++-- src/utils/resolve.test.ts | 37 +++++++++++++++++++++++++++++ src/utils/resolve.ts | 13 +++++++++-- 10 files changed, 238 insertions(+), 6 deletions(-) diff --git a/scripts/validate-data.mts b/scripts/validate-data.mts index 31ef1f9..52e93bd 100644 --- a/scripts/validate-data.mts +++ b/scripts/validate-data.mts @@ -132,6 +132,30 @@ function checkEra(era: unknown, i: number, errors: string[]): void { errors.push(`${label}.sources: must be an array of strings when present`); } } + if (e.anchorEvents !== undefined) { + if (!Array.isArray(e.anchorEvents)) { + errors.push(`${label}.anchorEvents: must be an array when present`); + } else { + e.anchorEvents.forEach((ev, j) => checkAnchorEvent(ev, `${label}.anchorEvents[${j}]`, errors)); + } + } +} + +// Anchor events are sourced, dateable reckoning points (see SCHEMA.md): each +// needs a name, a parseable proleptic-Gregorian date, and at least one source — +// the project never reckons "winters since" an undocumented event. +function checkAnchorEvent(ev: unknown, label: string, errors: string[]): void { + if (typeof ev !== 'object' || ev === null) { + errors.push(`${label}: not an object`); + return; + } + const a = ev as Record<string, unknown>; + if (typeof a.name !== 'string' || a.name.trim() === '') errors.push(`${label}.name: required non-empty string`); + if (typeof a.date !== 'string') errors.push(`${label}.date: required "YYYY-MM-DD" string`); + else checkDate(a.date, `${label}.date`, errors); + if (!Array.isArray(a.sources) || a.sources.length === 0 || !a.sources.every((s) => typeof s === 'string')) { + errors.push(`${label}.sources: required non-empty array of strings (anchor events must be sourced)`); + } } function validateFile(data: unknown, errors: string[]): void { diff --git a/src/components/ResultPanel.tsx b/src/components/ResultPanel.tsx index 5e57ce7..823fb66 100644 --- a/src/components/ResultPanel.tsx +++ b/src/components/ResultPanel.tsx @@ -60,6 +60,28 @@ function Sources({ sources }: { sources?: string[] }) { ); } +// "About N winters since <event>" — a reckoning a local could give when their +// calendar has no numbered year. The anchor event is sourced data, so we link it. +function Reckoning({ reckoning }: { reckoning?: { label: string; sources: string[] } }) { + if (!reckoning) return null; + return ( + <p style={{ margin: '6px 0 0', fontSize: '0.95em', color: '#444' }}> + … and {reckoning.label}. + <span style={{ fontSize: '0.85em', color: '#888' }}> + {' '} + {reckoning.sources.map((src, i) => ( + <React.Fragment key={src}> + {i > 0 && ' '} + <a href={src} target="_blank" rel="noopener noreferrer" style={{ color: '#888' }}> + [{i + 1}] + </a> + </React.Fragment> + ))} + </span> + </p> + ); +} + function AnswerCard({ answer }: { answer: Answer }) { const { era, conversion } = answer; const who = era.usedBy ?? 'a local'; @@ -100,6 +122,8 @@ function AnswerCard({ answer }: { answer: Answer }) { </> )} + <Reckoning reckoning={conversion.reckoning} /> + {era.description && ( <p style={{ margin: '8px 0 0', fontSize: '0.9em', color: '#444' }}>{era.description}</p> )} diff --git a/src/data/calendars/SCHEMA.md b/src/data/calendars/SCHEMA.md index 36b4b88..ff4159b 100644 --- a/src/data/calendars/SCHEMA.md +++ b/src/data/calendars/SCHEMA.md @@ -22,7 +22,14 @@ Each file describes a region and its calendar eras. "calendar": "string (calendar system name)", "usedBy": "string (optional — whose calendar this is, e.g. 'English colonists')", "description": "string (optional)", - "sources": ["string (URL or citation)"] + "sources": ["string (URL or citation)"], + "anchorEvents": [ + { + "name": "string (how a local names it, e.g. 'the great dying')", + "date": "YYYY-MM-DD (proleptic Gregorian, when it happened)", + "sources": ["string (URL or citation — required, ≥1)"] + } + ] } ] } @@ -52,3 +59,8 @@ of truth. - `usedBy` labels whose answer it is, shown as "Ask <usedBy>: …". - A calendar with no continuous numeric year (e.g. an observational lunar calendar) is shown descriptively rather than converted to a number. +- **`anchorEvents`** (optional) let such a calendar place you in time the way a + local would: the engine reports the most recent one at/before the date as + "about N winters since <event>". Each event needs a `name`, a proleptic- + Gregorian `date`, and at least one **`source`** — anchors must be documented + real events; the engine only counts the winters, it never invents an event. diff --git a/src/data/calendars/massachusett.json b/src/data/calendars/massachusett.json index 74f84a9..f76e10d 100644 --- a/src/data/calendars/massachusett.json +++ b/src/data/calendars/massachusett.json @@ -28,6 +28,15 @@ "https://en.wikipedia.org/wiki/Massachusett", "https://en.wikipedia.org/wiki/Algonquian_peoples", "https://en.wikipedia.org/wiki/King_Philip%27s_War" + ], + "anchorEvents": [ + { + "name": "the great dying (the 1616–1619 epidemic)", + "date": "1616-01-01", + "sources": [ + "https://en.wikipedia.org/wiki/1616_New_England_epidemic" + ] + } ] } ] diff --git a/src/data/index.ts b/src/data/index.ts index 961e389..86b73f6 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -6,6 +6,7 @@ // complementing the build-time `validate:data` step (see plan.md). import type { + AnchorEvent, CalendarRegion, Era, Geometry, @@ -39,6 +40,21 @@ function assertRegion(value: unknown, path: string): Region { return { name: r.name, geo: r.geo }; } +function assertAnchorEvents(value: unknown, path: string, i: number): AnchorEvent[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) fail(path, `eras[${i}].anchorEvents must be an array`); + return value.map((ev, j) => { + if (typeof ev !== 'object' || ev === null) fail(path, `eras[${i}].anchorEvents[${j}] not an object`); + const a = ev as Record<string, unknown>; + if (typeof a.name !== 'string') fail(path, `eras[${i}].anchorEvents[${j}].name must be a string`); + if (typeof a.date !== 'string') fail(path, `eras[${i}].anchorEvents[${j}].date must be YYYY-MM-DD`); + if (!Array.isArray(a.sources) || a.sources.length === 0) { + fail(path, `eras[${i}].anchorEvents[${j}].sources must be a non-empty array`); + } + return { name: a.name, date: a.date, sources: a.sources as string[] }; + }); +} + 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>; @@ -54,6 +70,7 @@ function assertEra(value: unknown, path: string, i: number): Era { 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, + anchorEvents: assertAnchorEvents(e.anchorEvents, path, i), }; } diff --git a/src/types/index.ts b/src/types/index.ts index 33c5601..83c9d05 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -55,6 +55,28 @@ export interface Era { description?: string; /** URLs or citations backing this era. */ sources?: string[]; + /** + * Documented events a local reckons time from (for calendars with no + * numbered year). The engine reports the most recent one at/before the date + * as "about N winters since <event>". Optional and back-compatible. + */ + anchorEvents?: AnchorEvent[]; +} + +/** + * A documented, dateable event a local could reckon time *from* — e.g. "the + * great dying" or a remembered eclipse. Used by calendars with no continuous + * numbered year to answer "When am I?" as "about N winters since <event>". + * Anchors are per-region curated data: each must be a real, sourced event — the + * engine only counts the winters, it never invents the event (see plan.md). + */ +export interface AnchorEvent { + /** Proleptic-Gregorian YYYY-MM-DD the event happened (or began). */ + date: string; + /** How a local would name it, e.g. "the great dying". */ + name: string; + /** URLs or citations documenting the event. Required: anchors must be sourced. */ + sources: string[]; } export interface CalendarRegion { @@ -99,6 +121,12 @@ export interface CalendarConversion { convertible: boolean; /** Optional clarifying note, esp. for descriptive-only calendars. */ note?: string; + /** + * Optional "about N winters since <event>" reckoning, computed from the + * era's `anchorEvents`. Present only when an anchor event precedes the date. + * Mainly enriches descriptive-only calendars, which have no numbered year. + */ + reckoning?: { label: string; sources: string[] }; } /** One possible reply a local could give to "When am I?". */ diff --git a/src/utils/calendars.test.ts b/src/utils/calendars.test.ts index 25f9d90..55701d4 100644 --- a/src/utils/calendars.test.ts +++ b/src/utils/calendars.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { convert } from './calendars'; +import { convert, wintersSince } from './calendars'; +import type { AnchorEvent } from '../types'; describe('convert', () => { it('Gregorian is the identity on the input year', () => { @@ -107,3 +108,33 @@ describe('convert', () => { expect(convert({ year: 2000, month: 1, day: 21 }, lunar).localDateLabel).toMatch(/full moon/i); }); }); + +describe('wintersSince', () => { + const events: AnchorEvent[] = [ + { name: 'the great dying', date: '1616-01-01', sources: ['https://example.org/a'] }, + { name: 'an earlier event', date: '1500-01-01', sources: ['https://example.org/b'] }, + ]; + + it('returns undefined with no anchor events', () => { + expect(wintersSince({ year: 1700, month: 1, day: 1 })).toBeUndefined(); + expect(wintersSince({ year: 1700, month: 1, day: 1 }, [])).toBeUndefined(); + }); + + it('counts whole years since the most recent anchor at or before the date', () => { + const r = wintersSince({ year: 1700, month: 1, day: 1 }, events); + expect(r?.label).toBe('about 84 winters since the great dying'); + expect(r?.sources).toEqual(['https://example.org/a']); + }); + + it('ignores anchor events that are still in the future', () => { + // 1550 is after the 1500 event but before the 1616 one → counts from 1500. + expect(wintersSince({ year: 1550, month: 1, day: 1 }, events)?.label).toBe('about 50 winters since an earlier event'); + // Before every anchor → no reckoning. + expect(wintersSince({ year: 1400, month: 1, day: 1 }, events)).toBeUndefined(); + }); + + it('reads as singular / sub-year near the anchor', () => { + expect(wintersSince({ year: 1616, month: 6, day: 1 }, events)?.label).toBe('less than a year since the great dying'); + expect(wintersSince({ year: 1617, month: 6, day: 1 }, events)?.label).toBe('about 1 winter since the great dying'); + }); +}); diff --git a/src/utils/calendars.ts b/src/utils/calendars.ts index 1daa457..ddc9121 100644 --- a/src/utils/calendars.ts +++ b/src/utils/calendars.ts @@ -11,8 +11,8 @@ // library-backed calendars go through @js-temporal/polyfill. import { Temporal } from '@js-temporal/polyfill'; -import type { CalendarConversion, HistoricalDate } from '../types'; -import { gregorianToJulianDay, julianDayToJulian } from './julianDay'; +import type { AnchorEvent, CalendarConversion, HistoricalDate } from '../types'; +import { gregorianToJulianDay, julianDayToJulian, parseGregorianDate } from './julianDay'; /** Year label in astronomical numbering: year <= 0 renders as "N BC". */ function yearLabel(year: number): string { @@ -69,6 +69,47 @@ function lunarPhaseLabel(date: HistoricalDate): string { return `About night ${night} of the current moon (${phase})`; } +// ── "Winters since" reckoning (per-region anchor events) ───────────────────── +// Calendars with no numbered year can still place you in time by counting from a +// remembered event ("about 84 winters since the great dying"). The events are +// curated, sourced per-region data (Era.anchorEvents): we only count whole years +// elapsed, never invent the event. We count completed calendar years (not +// days/365) so an exact anniversary reads as a whole number, not off-by-one from +// accumulated leap days. + +/** Completed years from an earlier proleptic-Gregorian date to a later one. */ +function completedYears(from: HistoricalDate, to: HistoricalDate): number { + let years = to.year - from.year; + if (to.month < from.month || (to.month === from.month && to.day < from.day)) years -= 1; + return years; +} + +/** + * The most recent anchor event at or before `date`, expressed as "about N + * winters since <event>" with that event's sources. Returns undefined when no + * anchor precedes the date (or none are given). + */ +export function wintersSince( + date: HistoricalDate, + anchorEvents?: AnchorEvent[], +): { label: string; sources: string[] } | undefined { + if (!anchorEvents || anchorEvents.length === 0) return undefined; + + const jd = gregorianToJulianDay(date); + let best: { event: AnchorEvent; date: HistoricalDate; jd: number } | undefined; + for (const event of anchorEvents) { + const eventDate = parseGregorianDate(event.date); + const eventJd = gregorianToJulianDay(eventDate); + if (eventJd <= jd && (!best || eventJd > best.jd)) best = { event, date: eventDate, jd: eventJd }; + } + if (!best) return undefined; + + const winters = completedYears(best.date, date); + const count = + winters <= 0 ? 'less than a year' : winters === 1 ? 'about 1 winter' : `about ${winters} winters`; + return { label: `${count} since ${best.event.name}`, sources: best.event.sources }; +} + type Converter = (date: HistoricalDate) => CalendarConversion; // Temporal calendar identifiers that resolve to a single continuous year count. diff --git a/src/utils/resolve.test.ts b/src/utils/resolve.test.ts index 7051f20..67fede5 100644 --- a/src/utils/resolve.test.ts +++ b/src/utils/resolve.test.ts @@ -60,6 +60,43 @@ describe('resolve', () => { expect(answers[0].conversion.convertible).toBe(false); }); + it('attaches the era\'s "winters since" reckoning to the conversion', () => { + const anchored: CalendarRegion[] = [ + { + region: { name: 'Anchorland', geo: square }, + eras: [ + { + name: 'Reckoning', start: null, end: null, + calendar: 'Algonquian lunar calendar', usedBy: 'locals', + anchorEvents: [{ name: 'the great dying', date: '1616-01-01', sources: ['https://example.org/x'] }], + }, + ], + }, + ]; + const { answers } = resolve(5, 5, { year: 1700, month: 7, day: 1 }, anchored); + expect(answers[0].conversion.reckoning).toEqual({ + label: 'about 84 winters since the great dying', + sources: ['https://example.org/x'], + }); + }); + + it('omits the reckoning when no anchor event precedes the date', () => { + const anchored: CalendarRegion[] = [ + { + region: { name: 'Anchorland', geo: square }, + eras: [ + { + name: 'Reckoning', start: null, end: null, + calendar: 'Algonquian lunar calendar', + anchorEvents: [{ name: 'a future event', date: '1900-01-01', sources: ['https://example.org/y'] }], + }, + ], + }, + ]; + const { answers } = resolve(5, 5, { year: 1700, month: 7, day: 1 }, anchored); + expect(answers[0].conversion.reckoning).toBeUndefined(); + }); + describe('modern global default', () => { it('adds a Gregorian default anywhere for modern dates with no region', () => { const { answers, missReason } = resolve(50, 50, { year: 2000, month: 6, day: 1 }, regions); diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index c5cd6b7..2937c02 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -7,7 +7,7 @@ import type { CalendarRegion, HistoricalDate, Answer, Resolution, Region, Era, Geometry } from '../types'; import { findRegions } from './geo'; import { findEras } from './eras'; -import { convert } from './calendars'; +import { convert, wintersSince } from './calendars'; import { gregorianToJulianDay } from './julianDay'; // ── Modern global default ───────────────────────────────────────────────────── @@ -62,7 +62,16 @@ export function resolve( const answers: Answer[] = []; for (const { region, eras, sourceFile } of matched) { for (const era of findEras(date, eras)) { - answers.push({ region, era, conversion: convert(date, era.calendar), sourceFile }); + const conversion = convert(date, era.calendar); + // Attach the era's "winters since <event>" reckoning when one applies + // (mainly for descriptive-only calendars with no numbered year). + const reckoning = wintersSince(date, era.anchorEvents); + answers.push({ + region, + era, + conversion: reckoning ? { ...conversion, reckoning } : conversion, + sourceFile, + }); } }