when-am-i / commit
feat: spell out answer dates, compute Algonquian moon phase, title sources
plan.md | 11 +++++--- screenshots/app/01-landing.png | Bin 338806 -> 332255 bytes screenshots/app/02-boston-1650.png | Bin 1548233 -> 1579818 bytes screenshots/app/03-boston-1800.png | Bin 1547441 -> 1576348 bytes src/App.tsx | 19 ------------- src/components/ResultPanel.tsx | 43 ++++++++++++++++++++++++----- src/utils/calendars.test.ts | 23 +++++++++++++--- src/utils/calendars.ts | 54 ++++++++++++++++++++++++++++++++++++- 8 files changed, 115 insertions(+), 35 deletions(-) diff --git a/plan.md b/plan.md index 19dce22..6a7b3f2 100644 --- a/plan.md +++ b/plan.md @@ -98,8 +98,9 @@ Mirror the JSON schema and add result types: 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. + needed** — the schema already has `sources`. 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 a @@ -202,8 +203,10 @@ answer a real local would give. Example target for **Boston, 1700-04-01**, asked 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. + - **Lunar phase / days since new moon** — *shipped*. `lunarPhaseLabel` in + `src/utils/calendars.ts` computes the night-of-the-moon + phase from the Julian Day + Number; the "Algonquian lunar calendar" answer now leads with it (still + `convertible: false`, no year) instead of the bare "no year count" note. - **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):** diff --git a/screenshots/app/01-landing.png b/screenshots/app/01-landing.png index 35531ed..c064ece 100644 Binary files a/screenshots/app/01-landing.png and b/screenshots/app/01-landing.png differ diff --git a/screenshots/app/02-boston-1650.png b/screenshots/app/02-boston-1650.png index 340af9f..cce4c39 100644 Binary files a/screenshots/app/02-boston-1650.png and b/screenshots/app/02-boston-1650.png differ diff --git a/screenshots/app/03-boston-1800.png b/screenshots/app/03-boston-1800.png index 4da6a63..ea4f880 100644 Binary files a/screenshots/app/03-boston-1800.png and b/screenshots/app/03-boston-1800.png differ diff --git a/src/App.tsx b/src/App.tsx index d86ecee..ae0adcd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,12 +59,6 @@ function App() { return items; }, [date]); - const legend = useMemo(() => { - const byCalendar = new Map<string, string>(); - for (const o of overlays) byCalendar.set(o.calendar, o.color); - return [...byCalendar.entries()].map(([cal, color]) => ({ calendar: cal, color })); - }, [overlays]); - // Era bands for the year scrollbar: the picked region's calendars over time. const yearBands = useMemo<YearBand[]>(() => { if (!picked) return []; @@ -135,19 +129,6 @@ function App() { initialZoom={initial.zoom} onPick={(lat, lng) => setPicked({ lat, lng })} /> - {legend.length > 0 && ( - <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginTop: 8, fontSize: '0.8em', color: '#555' }}> - {legend.map(({ calendar, color }) => ( - <span key={calendar} style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}> - <span - aria-hidden - style={{ width: 12, height: 12, background: color, border: '1px solid #ccc', borderRadius: 2 }} - /> - {calendar} - </span> - ))} - </div> - )} {picked && ( <div style={{ marginTop: 8, fontSize: '0.85em', color: '#888' }}> Pinned: {picked.lat.toFixed(4)}, {picked.lng.toFixed(4)} diff --git a/src/components/ResultPanel.tsx b/src/components/ResultPanel.tsx index ab1f95e..5e57ce7 100644 --- a/src/components/ResultPanel.tsx +++ b/src/components/ResultPanel.tsx @@ -12,6 +12,17 @@ function yearText(date: HistoricalDate): string { return date.year > 0 ? `${date.year} CE` : `${1 - date.year} BCE`; } +const MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +// Full modern date in the narrator's voice (CE/BCE), matching the page header — +// distinct from the local answer cards, which speak in AD/BC. See calendars.ts. +function fullDateText(date: HistoricalDate): string { + return `${MONTHS[date.month - 1]} ${date.day}, ${yearText(date)}`; +} + const cardStyle: React.CSSProperties = { border: '1px solid #e3e5e8', borderRadius: 8, @@ -19,6 +30,19 @@ const cardStyle: React.CSSProperties = { background: '#fff', }; +// Readable title from a source URL's last path segment — e.g. a Wikipedia +// ".../wiki/King_Philip%27s_War" becomes "King Philip's War". Falls back to the +// raw URL if it can't be parsed. +function sourceTitle(url: string): string { + try { + const { pathname, hostname } = new URL(url); + const slug = pathname.split('/').filter(Boolean).pop() ?? hostname; + return decodeURIComponent(slug).replace(/_/g, ' '); + } catch { + return url; + } +} + function Sources({ sources }: { sources?: string[] }) { if (!sources || sources.length === 0) return null; return ( @@ -28,7 +52,7 @@ function Sources({ sources }: { sources?: string[] }) { <React.Fragment key={src}> {i > 0 && ', '} <a href={src} target="_blank" rel="noopener noreferrer" style={{ color: '#888' }}> - [{i + 1}] + [{i + 1} - {sourceTitle(src)}] </a> </React.Fragment> ))} @@ -48,17 +72,22 @@ function AnswerCard({ answer }: { answer: Answer }) { {conversion.convertible ? ( <> - <div style={{ fontSize: '1.6em', fontWeight: 700 }}>{conversion.localYearLabel}</div> + <div style={{ fontSize: '1.6em', fontWeight: 700 }}> + {conversion.localDateLabel ?? conversion.localYearLabel} + </div> <div style={{ color: '#555', marginTop: 2 }}> {conversion.system} · {era.name} - {conversion.localDateLabel ? ` · ${conversion.localDateLabel}` : ''} </div> </> ) : ( <> - <div style={{ fontSize: '1.1em', fontWeight: 600 }}> - They don’t count years that way. - </div> + {conversion.localDateLabel ? ( + <div style={{ fontSize: '1.25em', fontWeight: 700 }}>{conversion.localDateLabel}</div> + ) : ( + <div style={{ fontSize: '1.1em', fontWeight: 600 }}> + They don’t count years that way. + </div> + )} <div style={{ color: '#555', marginTop: 4 }}> {conversion.system} · {era.name} </div> @@ -122,7 +151,7 @@ const ResultPanel: React.FC<ResultPanelProps> = ({ picked, date, resolution }) = return ( <div style={{ marginTop: 16 }}> <p style={{ color: '#555', marginTop: 0 }}> - In <strong>{yearText(date)}</strong>, here’s what you’d hear if you asked + On <strong>{fullDateText(date)}</strong>, here’s what you’d hear if you asked “When am I?”: </p> <ul style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 0, margin: 0 }}> diff --git a/src/utils/calendars.test.ts b/src/utils/calendars.test.ts index ac90b04..d32c45b 100644 --- a/src/utils/calendars.test.ts +++ b/src/utils/calendars.test.ts @@ -5,7 +5,7 @@ describe('convert', () => { it('Gregorian is the identity on the input year', () => { const c = convert({ year: 1700, month: 3, day: 22 }, 'Gregorian'); expect(c).toMatchObject({ system: 'Gregorian', localYearLabel: '1700', convertible: true }); - expect(c.localDateLabel).toBe('1700-03-22'); + expect(c.localDateLabel).toBe('March 22, 1700 AD'); }); it('is case/space-insensitive on the calendar name', () => { @@ -15,7 +15,7 @@ describe('convert', () => { it('Julian converts 1582-10-15 Gregorian to 1582-10-05', () => { const c = convert({ year: 1582, month: 10, day: 15 }, 'Julian'); expect(c).toMatchObject({ system: 'Julian', localYearLabel: '1582', convertible: true }); - expect(c.localDateLabel).toBe('1582-10-05'); + expect(c.localDateLabel).toBe('October 5, 1582 AD'); }); it('labels BCE years using astronomical numbering', () => { @@ -47,11 +47,26 @@ describe('convert', () => { }); it('falls back to descriptive-only for unknown calendars', () => { - const c = convert({ year: 1700, month: 4, day: 1 }, 'Algonquian lunar calendar'); + const c = convert({ year: 1700, month: 4, day: 1 }, 'Some Unknown Calendar'); expect(c.convertible).toBe(false); - expect(c.system).toBe('Algonquian lunar calendar'); + expect(c.system).toBe('Some Unknown Calendar'); expect(c.localYearLabel).toBe('1700'); // Gregorian reference expect(c.note).toBeTruthy(); expect(c.localDateLabel).toBeUndefined(); }); + + it('Algonquian lunar calendar is descriptive-only but reports the moon', () => { + const c = convert({ year: 1700, month: 4, day: 1 }, 'Algonquian lunar calendar'); + expect(c.convertible).toBe(false); // no continuous numeric year + expect(c.system).toBe('Algonquian lunar calendar'); + expect(c.localYearLabel).toBe('1700'); // Gregorian reference + expect(c.localDateLabel).toBeTruthy(); // computed moon phase + }); + + it('computes the moon phase from astronomy', () => { + // 2000-01-06 was a new moon; ~2000-01-21 was the following full moon. + const lunar = 'Algonquian lunar calendar'; + expect(convert({ year: 2000, month: 1, day: 6 }, lunar).localDateLabel).toMatch(/new moon/i); + expect(convert({ year: 2000, month: 1, day: 21 }, lunar).localDateLabel).toMatch(/full moon/i); + }); }); diff --git a/src/utils/calendars.ts b/src/utils/calendars.ts index d109d8b..2e7ae7d 100644 --- a/src/utils/calendars.ts +++ b/src/utils/calendars.ts @@ -23,14 +23,56 @@ function yearLabel(year: number): string { return year > 0 ? String(year) : `${1 - year} BC`; } +const MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +// Spelled-out date for the local-voice answer cards. Uses AD/BC (not the +// modern CE/BCE the page header/slider use) because the cards represent what a +// local would actually have said — "Anno Domini" was the live convention for +// the Julian/Gregorian Christian calendars this label serves. function dateLabel({ year, month, day }: HistoricalDate): string { - return `${year}-${pad2(month)}-${pad2(day)}`; + const era = year > 0 ? 'AD' : 'BC'; + const displayYear = year > 0 ? year : 1 - year; + return `${MONTHS[month - 1]} ${day}, ${displayYear} ${era}`; } function descriptiveOnly(date: HistoricalDate, system: string, note: string): CalendarConversion { return { system, localYearLabel: yearLabel(date.year), convertible: false, note }; } +// ── Lunar phase (for descriptive lunar calendars) ──────────────────────────── +// A lunar calendar has no continuous numeric year, but it does reckon the moon +// and the night within it — which is what "When am I?" asks. The synodic month +// and the reference new moon below are standard astronomical constants; we work +// from the integer Julian Day Number (julianDay.ts), so this is a +// date-granularity approximation: enough to say which night of the moon it is, +// not a precise instant. No culture-specific moon *names* are asserted here — +// those need sourced data (see the project's acceptance gate). +const SYNODIC_MONTH = 29.530588853; // mean days between successive new moons +const REFERENCE_NEW_MOON_JD = 2451550.1; // new moon of 2000-01-06 18:14 UTC + +/** A computed, name-free description of the moon on `date`, e.g. "About the 8th night of the current moon (a waxing crescent)". */ +function lunarPhaseLabel(date: HistoricalDate): string { + let frac = ((gregorianToJulianDay(date) - REFERENCE_NEW_MOON_JD) / SYNODIC_MONTH) % 1; + if (frac < 0) frac += 1; + const night = Math.floor(frac * SYNODIC_MONTH) + 1; // 1-based night of this moon + + if (frac < 0.02 || frac > 0.98) return 'A new moon — the first night of a new moon'; + if (frac >= 0.48 && frac < 0.52) return `A full moon — about night ${night} of the current moon`; + + let phase: string; + if (frac < 0.23) phase = 'a waxing crescent'; + else if (frac < 0.27) phase = 'the first quarter'; + else if (frac < 0.48) phase = 'a waxing gibbous'; + else if (frac < 0.73) phase = 'a waning gibbous'; + else if (frac < 0.77) phase = 'the last quarter'; + else phase = 'a waning crescent'; + + return `About night ${night} of the current moon (${phase})`; +} + type Converter = (date: HistoricalDate) => CalendarConversion; // Temporal calendar identifiers that resolve to a single continuous year count. @@ -91,6 +133,16 @@ for (const { names, def } of TEMPORAL_CALENDARS) { for (const name of names) CONVERTERS[name] = converter; } +// Observational lunar calendars: no continuous year (convertible:false), but we +// can compute the moon and night to answer "When am I?" beyond just the year. +CONVERTERS['algonquian lunar calendar'] = (date) => ({ + system: 'Algonquian lunar calendar', + localYearLabel: yearLabel(date.year), // Gregorian reference year + localDateLabel: lunarPhaseLabel(date), + convertible: false, + note: 'This calendar reckons time by moons and seasons, not a numbered year. The moon above is computed astronomically; the season-based names the Massachusett gave their moons aren’t recorded in this dataset yet.', +}); + /** Convert `date` into the named calendar's local year. */ export function convert(date: HistoricalDate, calendarName: string): CalendarConversion { const converter = CONVERTERS[calendarName.trim().toLowerCase()];