fuzzyworld

when-am-i / commit

feat: spell out answer dates, compute Algonquian moon phase, title sources

8961f70b622491abbbb6860ba55396d6f5b18fa7 · Pavel Safronov · 2026-06-20 09:03:27 -0700 · parents 26a02b71de43

feat: spell out answer dates, compute Algonquian moon phase, title sources

- Answer cards: full spelled-out date as the headline, in AD/BC (what a
  local would say) instead of the "1700-06-08" shorthand. The page
  header/slider/narration keep modern CE/BCE.
- Algonquian lunar calendar: still year-less, but now reports the
  computed moon + night-of-the-moon (lunarPhaseLabel) rather than the
  bare "they don't count years that way" note.
- Intro line now states the full modern date ("On June 19, 1700 CE, ...").
- Removed the duplicate under-map legend; the timeline band legend covers
  both the map and the sliders.
- Sources render the article title: [1 - Massachusett] etc.
- plan.md: mark the lunar-phase stretch item as shipped.
- Regenerated app screenshots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 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&rsquo;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&rsquo;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&rsquo;s what you&rsquo;d hear if you asked
+                On <strong>{fullDateText(date)}</strong>, here&rsquo;s what you&rsquo;d hear if you asked
                 &ldquo;When am I?&rdquo;:
             </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()];