fuzzyworld

when-am-i / commit

feat: per-region "winters since" anchor events

5c66fc6a65d525343f19968cbc7f087f57798a8b · Pavel Safronov · 2026-06-20 18:31:23 -0700 · parents 4c1ec1d7830a

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 3 tally 29.0K in / 269.0K out / 22.9M cached · usage by commit and session

feat: per-region "winters since" anchor events

Calendars with no numbered year can now place you in time the way a local would:
"about N winters since <event>". Each anchor is documented, sourced per-region
data (Era.anchorEvents = { name, date, sources }[]); the engine only counts
completed calendar years, never invents the event.

- types: AnchorEvent + CalendarConversion.reckoning
- wintersSince() in calendars.ts picks the most recent anchor at/before the date
- resolve() attaches it; ResultPanel renders a sourced line
- validated in validate-data.mts and the data loader (which previously dropped
  unknown era fields); documented in SCHEMA.md
- massachusett.json carries the first anchor (the 1616-1619 epidemic)

Usage: 29.0K in / 269.0K out / 22.9M cached (session tally)
rtk:   110.8K saved, 80.9% over 243 cmds (global/lifetime)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 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' }}>
+            &hellip; 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 &lt;usedBy&gt;: …".
 - 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 &lt;event&gt;". 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,
+            });
         }
     }