fuzzyworld

when-am-i / commit

feat: interactive UI — date picker, answer panel, map overlays, data links

34c4dd59f826cc13e22aec0d1513804358c20e7b · Pavel Safronov · 2026-06-18 15:54:35 -0700 · parents d7aad2c59d39

feat: interactive UI — date picker, answer panel, map overlays, data links

Wire the resolution engine into the page and render the plural-answer model:

- DatePicker: month/day/year + BCE/CE, with day-of-month clamping.
- ResultPanel: "what you'd hear" list of answers, descriptive-only handling,
  per-answer sources, miss states (no-region / no-era), and a "View calendar
  data" link to each region's source JSON on GitLab.
- App: resolves the picked point + date over the loaded calendar data; supports
  initial state from URL query params (?lat=&lng=&year=&month=&day=&era=&zoom=)
  as a shareable link and the deterministic hook for screenshots.
- MapPicker: controlled marker that frames on load, plus region overlays shaded
  by calendar for the selected date (dark border so coastal regions read), with
  a color legend (calendarColor.ts: curated palette + deterministic fallback).
- Data loader tracks each region's source file (sourceFile), threaded through
  resolve() onto Answer for the data links.
- Fix: render-time report links now take the live app context explicitly, so
  the footer "Report an issue" link includes the picked location and date
  instead of stale/empty context.

Engine stays pure and data-agnostic; new pure helpers (historicalDate, urlState,
calendarColor) are unit-tested. 52 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 .gitignore                       |   1 +
 src/App.tsx                      | 114 ++++++++++++++++++++++++++++----
 src/components/Attribution.tsx   |   6 +-
 src/components/DatePicker.tsx    |  85 ++++++++++++++++++++++++
 src/components/MapPicker.tsx     |  64 ++++++++++++++----
 src/components/ResultPanel.tsx   | 137 +++++++++++++++++++++++++++++++++++++++
 src/data/index.test.ts           |   1 +
 src/data/index.ts                |   5 +-
 src/types/index.ts               |   4 ++
 src/utils/calendarColor.test.ts  |  20 ++++++
 src/utils/calendarColor.ts       |  30 +++++++++
 src/utils/historicalDate.test.ts |  48 ++++++++++++++
 src/utils/historicalDate.ts      |  47 ++++++++++++++
 src/utils/report.ts              |  27 +++++---
 src/utils/resolve.test.ts        |   2 +
 src/utils/resolve.ts             |   4 +-
 src/utils/urlState.test.ts       |  32 +++++++++
 src/utils/urlState.ts            |  43 ++++++++++++
 18 files changed, 628 insertions(+), 42 deletions(-)

diff --git a/.gitignore b/.gitignore
index 043ca9b..cc733c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ node_modules
 dist
 public/*
 !public/favicon.png
+.playwright-mcp
diff --git a/src/App.tsx b/src/App.tsx
index 393916f..58130c1 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,17 +1,76 @@
 import calendar from './assets/calendar.png';
-import React, { useEffect, useState } from 'react';
-import { MapPicker } from './components/MapPicker';
+import React, { useEffect, useMemo, useState } from 'react';
+import { MapPicker, type MapOverlay } from './components/MapPicker';
+import { DatePicker } from './components/DatePicker';
+import { ResultPanel } from './components/ResultPanel';
 import { Attribution } from './components/Attribution';
-import { reportMailtoHref, setReportContext, subscribeReportErrors } from './utils/report';
+import { regions } from './data';
+import { resolve } from './utils/resolve';
+import { findEras } from './utils/eras';
+import { calendarColor } from './utils/calendarColor';
+import {
+    fromHistoricalDate,
+    toHistoricalDate,
+    type PickerDate,
+} from './utils/historicalDate';
+import { parseInitialState } from './utils/urlState';
+import {
+    reportMailtoHref,
+    setReportContext,
+    subscribeReportErrors,
+    type ReportContext,
+} from './utils/report';
+
+const initial = parseInitialState(typeof window !== 'undefined' ? window.location.search : '');
+
+const now = new Date();
+const defaultDate: PickerDate = initial.date
+    ? fromHistoricalDate(initial.date)
+    : { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), era: 'CE' };
+
+const sectionLabel: React.CSSProperties = { margin: '0 0 4px', fontSize: '0.85em', fontWeight: 600, color: '#555' };
 
 function App() {
-    const [picked, setPicked] = useState<{ lat: number; lng: number } | null>(null);
+    const [picked, setPicked] = useState<{ lat: number; lng: number } | null>(initial.picked ?? null);
+    const [pickerDate, setPickerDate] = useState<PickerDate>(defaultDate);
     const [runtimeError, setRuntimeError] = useState<unknown>(null);
 
-    // Keep error reports in sync with the current app state.
-    useEffect(() => {
-        setReportContext({ picked });
-    }, [picked]);
+    const date = useMemo(() => toHistoricalDate(pickerDate), [pickerDate]);
+    const resolution = useMemo(
+        () => (picked ? resolve(picked.lat, picked.lng, date, regions) : null),
+        [picked, date],
+    );
+
+    // Shade every region in use at this date, colored by its calendar.
+    const overlays = useMemo<(MapOverlay & { calendar: string })[]>(() => {
+        const items: (MapOverlay & { calendar: string })[] = [];
+        for (const cr of regions) {
+            for (const era of findEras(date, cr.eras)) {
+                items.push({
+                    id: `${cr.sourceFile ?? cr.region.name}::${era.name}`,
+                    geometry: cr.region.geo,
+                    color: calendarColor(era.calendar),
+                    calendar: era.calendar,
+                });
+            }
+        }
+        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]);
+
+    const reportContext = useMemo<ReportContext>(() => {
+        const dateText = date.year > 0 ? `${date.year} CE` : `${1 - date.year} BCE`;
+        return { picked, date: `${dateText} (${date.year}-${date.month}-${date.day})` };
+    }, [picked, date]);
+
+    // Keep the async error handlers' context in sync (render-time links below
+    // pass reportContext explicitly so they're never stale).
+    useEffect(() => setReportContext(reportContext), [reportContext]);
 
     // Surface async errors caught by the global handlers as a dismissible banner.
     useEffect(() => subscribeReportErrors(setRuntimeError), []);
@@ -25,8 +84,9 @@ function App() {
             <p style={{ maxWidth: 640 }}>
                 You&rsquo;re a time traveler who lands somewhere in history. You stop a local
                 and ask: <em>&ldquo;What year is it?&rdquo;</em> Their answer depends on where
-                you are &mdash; and who you ask. Pick a spot on the map to begin.
+                you are &mdash; and who you ask. Pick a date and a spot on the map to begin.
             </p>
+
             {runtimeError != null && (
                 <div
                     role="alert"
@@ -39,19 +99,45 @@ function App() {
                     }}
                 >
                     An unexpected error occurred.{' '}
-                    <a href={reportMailtoHref(runtimeError)}>Send an error report</a>{' '}
+                    <a href={reportMailtoHref(runtimeError, reportContext)}>Send an error report</a>{' '}
                     <button type="button" onClick={() => setRuntimeError(null)}>
                         Dismiss
                     </button>
                 </div>
             )}
-            <MapPicker onPick={(lat: number, lng: number) => setPicked({ lat, lng })} />
+
+            <p style={sectionLabel}>When?</p>
+            <DatePicker value={pickerDate} onChange={setPickerDate} />
+
+            <p style={sectionLabel}>Where?</p>
+            <MapPicker
+                marker={picked ? [picked.lat, picked.lng] : null}
+                overlays={overlays}
+                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: 16 }}>
-                    <strong>Picked location:</strong> {picked.lat.toFixed(4)}, {picked.lng.toFixed(4)}
+                <div style={{ marginTop: 8, fontSize: '0.85em', color: '#888' }}>
+                    Pinned: {picked.lat.toFixed(4)}, {picked.lng.toFixed(4)}
                 </div>
             )}
-            <Attribution />
+
+            <ResultPanel picked={picked} date={date} resolution={resolution} />
+
+            <Attribution reportContext={reportContext} />
         </div>
     );
 }
diff --git a/src/components/Attribution.tsx b/src/components/Attribution.tsx
index 3e8bd51..01823c9 100644
--- a/src/components/Attribution.tsx
+++ b/src/components/Attribution.tsx
@@ -1,11 +1,11 @@
 import React from 'react';
-import { REPO_URL, reportMailtoHref } from '../utils/report';
+import { REPO_URL, reportMailtoHref, type ReportContext } from '../utils/report';
 
-const Attribution: React.FC = () => (
+const Attribution: React.FC<{ reportContext?: ReportContext }> = ({ reportContext }) => (
     <footer style={{ marginTop: 32, color: '#555' }}>
         <p style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: '0.95em' }}>
             <a href={REPO_URL} target="_blank" rel="noopener noreferrer">Source code</a>
-            <a href={reportMailtoHref()}>Report an issue</a>
+            <a href={reportMailtoHref(undefined, reportContext)}>Report an issue</a>
         </p>
         <p
             style={{
diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx
new file mode 100644
index 0000000..135935c
--- /dev/null
+++ b/src/components/DatePicker.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { clampDay, daysInMonth, type Era, type PickerDate } from '../utils/historicalDate';
+
+interface DatePickerProps {
+    value: PickerDate;
+    onChange: (next: PickerDate) => void;
+}
+
+const MONTHS = [
+    'January', 'February', 'March', 'April', 'May', 'June',
+    'July', 'August', 'September', 'October', 'November', 'December',
+];
+
+const fieldStyle: React.CSSProperties = { padding: '6px 8px', fontSize: '1em' };
+
+const DatePicker: React.FC<DatePickerProps> = ({ value, onChange }) => {
+    const astronomicalYear = value.era === 'BCE' ? 1 - value.year : value.year;
+    const maxDay = daysInMonth(astronomicalYear, value.month);
+
+    // Apply a single-field change, then clamp the day so e.g. switching to
+    // February can't leave day 31 selected.
+    const update = (patch: Partial<PickerDate>) => onChange(clampDay({ ...value, ...patch }));
+
+    return (
+        <div
+            style={{
+                display: 'flex',
+                gap: 8,
+                alignItems: 'flex-end',
+                flexWrap: 'wrap',
+                margin: '16px 0',
+            }}
+        >
+            <label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: '0.85em', color: '#555' }}>
+                Month
+                <select
+                    style={fieldStyle}
+                    value={value.month}
+                    onChange={(e) => update({ month: Number(e.target.value) })}
+                >
+                    {MONTHS.map((name, i) => (
+                        <option key={name} value={i + 1}>{name}</option>
+                    ))}
+                </select>
+            </label>
+
+            <label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: '0.85em', color: '#555' }}>
+                Day
+                <input
+                    style={{ ...fieldStyle, width: 64 }}
+                    type="number"
+                    min={1}
+                    max={maxDay}
+                    value={value.day}
+                    onChange={(e) => update({ day: Number(e.target.value) })}
+                />
+            </label>
+
+            <label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: '0.85em', color: '#555' }}>
+                Year
+                <input
+                    style={{ ...fieldStyle, width: 96 }}
+                    type="number"
+                    min={1}
+                    value={value.year}
+                    onChange={(e) => update({ year: Math.max(1, Number(e.target.value)) })}
+                />
+            </label>
+
+            <label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: '0.85em', color: '#555' }}>
+                Era
+                <select
+                    style={fieldStyle}
+                    value={value.era}
+                    onChange={(e) => update({ era: e.target.value as Era })}
+                >
+                    <option value="CE">CE</option>
+                    <option value="BCE">BCE</option>
+                </select>
+            </label>
+        </div>
+    );
+};
+
+export { DatePicker };
diff --git a/src/components/MapPicker.tsx b/src/components/MapPicker.tsx
index 11664c5..defef1e 100644
--- a/src/components/MapPicker.tsx
+++ b/src/components/MapPicker.tsx
@@ -1,19 +1,32 @@
-import React, { useState } from 'react';
-import { MapContainer, TileLayer, Marker, useMapEvents } from 'react-leaflet';
+import React from 'react';
+import { MapContainer, TileLayer, Marker, Polygon, useMapEvents } from 'react-leaflet';
 import type { LeafletMouseEvent } from 'leaflet';
+import type { Geometry, LinearRing } from '../types';
 import 'leaflet/dist/leaflet.css';
 
+/** A region polygon to shade on the map for the current date. */
+export interface MapOverlay {
+    id: string;
+    geometry: Geometry;
+    color: string;
+}
+
 interface MapPickerProps {
+    /** The currently pinned location (owned by the parent), or null. */
+    marker: [number, number] | null;
+    overlays: MapOverlay[];
+    /** Initial zoom override (e.g. from a shared/demo URL). */
+    initialZoom?: number;
     onPick: (lat: number, lng: number) => void;
 }
 
 // Hoisted out of MapPicker so it isn't recreated on every render (which would
-// remount the Leaflet click handler). Renders the marker at the picked spot.
+// remount the Leaflet click handler). Renders the marker at the pinned spot.
 function LocationMarker({
-    position,
+    marker,
     onSelect,
 }: {
-    position: [number, number] | null;
+    marker: [number, number] | null;
     onSelect: (lat: number, lng: number) => void;
 }) {
     useMapEvents({
@@ -21,24 +34,47 @@ function LocationMarker({
             onSelect(e.latlng.lat, e.latlng.lng);
         },
     });
-    return position ? <Marker position={position} /> : null;
+    return marker ? <Marker position={marker} /> : null;
+}
+
+// GeoJSON rings are [lng, lat]; Leaflet wants [lat, lng].
+function ringsToLatLng(rings: LinearRing[]): [number, number][][] {
+    return rings.map((ring) => ring.map(([lng, lat]) => [lat, lng] as [number, number]));
 }
 
-const MapPicker: React.FC<MapPickerProps> = ({ onPick }) => {
-    const [position, setPosition] = useState<[number, number] | null>(null);
+function OverlayShape({ overlay }: { overlay: MapOverlay }) {
+    // Neutral dark border so the region reads even when its fill matches nearby
+    // terrain/water; the fill color carries the calendar identity (the legend).
+    const pathOptions = { color: '#333', weight: 2, opacity: 0.85, fillColor: overlay.color, fillOpacity: 0.55 };
+    const { geometry } = overlay;
+    if (geometry.type === 'Polygon') {
+        return <Polygon positions={ringsToLatLng(geometry.coordinates)} pathOptions={pathOptions} />;
+    }
+    return (
+        <>
+            {geometry.coordinates.map((polygon, i) => (
+                <Polygon key={i} positions={ringsToLatLng(polygon)} pathOptions={pathOptions} />
+            ))}
+        </>
+    );
+}
 
-    const handleSelect = (lat: number, lng: number) => {
-        setPosition([lat, lng]);
-        onPick(lat, lng);
-    };
+const MapPicker: React.FC<MapPickerProps> = ({ marker, overlays, initialZoom, onPick }) => {
+    // Initial view only (Leaflet ignores later center/zoom prop changes): if a
+    // location is pinned at mount — e.g. from a shared/demo URL — frame it.
+    const [center] = React.useState<[number, number]>(marker ?? [20, 0]);
+    const [zoom] = React.useState(initialZoom ?? (marker ? 6 : 2));
 
     return (
-        <MapContainer center={[20, 0]} zoom={2} style={{ height: '400px', width: '100%' }}>
+        <MapContainer center={center} zoom={zoom} style={{ height: '400px', width: '100%' }}>
             <TileLayer
                 attribution='&copy; OpenStreetMap contributors'
                 url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
             />
-            <LocationMarker position={position} onSelect={handleSelect} />
+            {overlays.map((overlay) => (
+                <OverlayShape key={`${overlay.id}-${overlay.color}`} overlay={overlay} />
+            ))}
+            <LocationMarker marker={marker} onSelect={onPick} />
         </MapContainer>
     );
 };
diff --git a/src/components/ResultPanel.tsx b/src/components/ResultPanel.tsx
new file mode 100644
index 0000000..a753214
--- /dev/null
+++ b/src/components/ResultPanel.tsx
@@ -0,0 +1,137 @@
+import React from 'react';
+import type { Answer, HistoricalDate, Resolution } from '../types';
+import { REPO_URL, repoFileUrl } from '../utils/report';
+
+interface ResultPanelProps {
+    picked: { lat: number; lng: number } | null;
+    date: HistoricalDate;
+    resolution: Resolution | null;
+}
+
+function yearText(date: HistoricalDate): string {
+    return date.year > 0 ? `${date.year} CE` : `${1 - date.year} BCE`;
+}
+
+const cardStyle: React.CSSProperties = {
+    border: '1px solid #e3e5e8',
+    borderRadius: 8,
+    padding: 16,
+    background: '#fff',
+};
+
+function Sources({ sources }: { sources?: string[] }) {
+    if (!sources || sources.length === 0) return null;
+    return (
+        <p style={{ margin: '8px 0 0', fontSize: '0.8em', color: '#888' }}>
+            Sources:{' '}
+            {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>
+            ))}
+        </p>
+    );
+}
+
+function AnswerCard({ answer }: { answer: Answer }) {
+    const { era, conversion } = answer;
+    const who = era.usedBy ?? 'a local';
+
+    return (
+        <li style={{ ...cardStyle, listStyle: 'none' }}>
+            <div style={{ fontSize: '0.85em', color: '#555', marginBottom: 4 }}>
+                Ask <strong>{who}</strong>:
+            </div>
+
+            {conversion.convertible ? (
+                <>
+                    <div style={{ fontSize: '1.6em', fontWeight: 700 }}>{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>
+                    <div style={{ color: '#555', marginTop: 4 }}>
+                        {conversion.system} · {era.name}
+                    </div>
+                    {conversion.note && (
+                        <p style={{ margin: '8px 0 0', fontSize: '0.9em', color: '#666' }}>{conversion.note}</p>
+                    )}
+                    <div style={{ marginTop: 4, fontSize: '0.85em', color: '#888' }}>
+                        Gregorian reference: {conversion.localYearLabel}
+                    </div>
+                </>
+            )}
+
+            {era.description && (
+                <p style={{ margin: '8px 0 0', fontSize: '0.9em', color: '#444' }}>{era.description}</p>
+            )}
+            <Sources sources={era.sources} />
+            {answer.sourceFile && (
+                <p style={{ margin: '6px 0 0', fontSize: '0.8em' }}>
+                    <a href={repoFileUrl(answer.sourceFile)} target="_blank" rel="noopener noreferrer">
+                        View calendar data ({answer.region.name})
+                    </a>
+                </p>
+            )}
+        </li>
+    );
+}
+
+const ResultPanel: React.FC<ResultPanelProps> = ({ picked, date, resolution }) => {
+    if (!picked || !resolution) {
+        return (
+            <p style={{ color: '#666', marginTop: 16 }}>
+                Pick a spot on the map to hear what the locals would say.
+            </p>
+        );
+    }
+
+    if (resolution.missReason === 'no-region') {
+        return (
+            <div style={{ ...cardStyle, marginTop: 16, color: '#555' }}>
+                <strong>No calendar data for this spot.</strong>
+                <p style={{ margin: '8px 0 0', fontSize: '0.9em' }}>
+                    No region in the dataset covers this exact point. The <strong>shaded areas</strong>{' '}
+                    on the map have calendar data for this date — pin one of those, or{' '}
+                    <a href={REPO_URL} target="_blank" rel="noopener noreferrer">add a calendar on GitLab</a>.
+                </p>
+            </div>
+        );
+    }
+
+    if (resolution.missReason === 'no-era') {
+        return (
+            <div style={{ ...cardStyle, marginTop: 16, color: '#555' }}>
+                <strong>No era covers {yearText(date)} here.</strong>
+                <p style={{ margin: '8px 0 0', fontSize: '0.9em' }}>
+                    There&rsquo;s calendar data for this area, but none spanning that date yet. Try another year.
+                </p>
+            </div>
+        );
+    }
+
+    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
+                &ldquo;What year is it?&rdquo;:
+            </p>
+            <ul style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 0, margin: 0 }}>
+                {resolution.answers.map((answer, i) => (
+                    <AnswerCard key={`${answer.region.name}-${answer.era.name}-${i}`} answer={answer} />
+                ))}
+            </ul>
+        </div>
+    );
+};
+
+export { ResultPanel };
diff --git a/src/data/index.test.ts b/src/data/index.test.ts
index 679d371..7648fff 100644
--- a/src/data/index.test.ts
+++ b/src/data/index.test.ts
@@ -12,6 +12,7 @@ describe('calendar data loader', () => {
         const boston = regions.find((r) => r.region.name === 'Boston Area');
         expect(boston).toBeDefined();
         expect(boston!.region.geo.type).toBe('Polygon');
+        expect(boston!.sourceFile).toBe('src/data/calendars/boston.json');
         expect(boston!.eras.length).toBeGreaterThan(0);
         for (const era of boston!.eras) {
             expect(era.name).toBeTruthy();
diff --git a/src/data/index.ts b/src/data/index.ts
index f2719f3..8ad552d 100644
--- a/src/data/index.ts
+++ b/src/data/index.ts
@@ -63,7 +63,10 @@ function assertCalendarRegion(value: unknown, path: string): CalendarRegion {
     const region = assertRegion(v.region, path);
     if (!Array.isArray(v.eras)) fail(path, 'eras must be an array');
     const eras = v.eras.map((era, i) => assertEra(era, path, i));
-    return { region, eras };
+    // glob keys look like "./calendars/boston.json"; store the repo-relative
+    // path so the UI can link to the source file on GitLab.
+    const sourceFile = `src/data/${path.replace(/^\.\//, '')}`;
+    return { region, eras, sourceFile };
 }
 
 /** All calendar regions, validated. Throws on the first malformed file. */
diff --git a/src/types/index.ts b/src/types/index.ts
index 25a4bc7..4e02499 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -59,6 +59,8 @@ export interface Era {
 export interface CalendarRegion {
     region: Region;
     eras: Era[];
+    /** Repo-relative path of the JSON file this region was loaded from. */
+    sourceFile?: string;
 }
 
 // ── Engine input ──────────────────────────────────────────────────────────────
@@ -103,6 +105,8 @@ export interface Answer {
     region: Region;
     era: Era;
     conversion: CalendarConversion;
+    /** Repo-relative path of the source JSON file, for citation links. */
+    sourceFile?: string;
 }
 
 /** Why the engine produced no answers. */
diff --git a/src/utils/calendarColor.test.ts b/src/utils/calendarColor.test.ts
new file mode 100644
index 0000000..4c1aa55
--- /dev/null
+++ b/src/utils/calendarColor.test.ts
@@ -0,0 +1,20 @@
+import { describe, it, expect } from 'vitest';
+import { calendarColor } from './calendarColor';
+
+describe('calendarColor', () => {
+    it('uses the curated palette for known calendars', () => {
+        expect(calendarColor('Gregorian')).toBe('#90ee90');
+        expect(calendarColor('Algonquian lunar calendar')).toBe('#add8e6');
+    });
+
+    it('is case/space-insensitive', () => {
+        expect(calendarColor('  gregorian ')).toBe('#90ee90');
+    });
+
+    it('is deterministic for unknown calendars', () => {
+        const a = calendarColor('Some Made-Up Calendar');
+        const b = calendarColor('Some Made-Up Calendar');
+        expect(a).toBe(b);
+        expect(a).toMatch(/^hsl\(/);
+    });
+});
diff --git a/src/utils/calendarColor.ts b/src/utils/calendarColor.ts
new file mode 100644
index 0000000..105f037
--- /dev/null
+++ b/src/utils/calendarColor.ts
@@ -0,0 +1,30 @@
+// Stable colors per calendar, for map overlays and the legend.
+//
+// A small curated palette keeps the common calendars recognizable; anything
+// else gets a deterministic pastel derived from the name, so colors are stable
+// across renders and the same calendar always looks the same.
+
+const PALETTE: Record<string, string> = {
+    'algonquian lunar calendar': '#add8e6', // light blue
+    gregorian: '#90ee90', // light green
+    julian: '#f4d35e', // gold
+    'islamic (umm al-qura)': '#c5b3e6', // lavender
+    islamic: '#c5b3e6',
+    hebrew: '#a8d5ba', // sage
+    'persian (solar hijri)': '#f4a6a6', // salmon
+    persian: '#f4a6a6',
+};
+
+function hashHue(value: string): number {
+    let hash = 0;
+    for (let i = 0; i < value.length; i++) {
+        hash = (hash * 31 + value.charCodeAt(i)) % 360;
+    }
+    return hash;
+}
+
+/** A stable CSS color for a calendar name. */
+export function calendarColor(calendarName: string): string {
+    const key = calendarName.trim().toLowerCase();
+    return PALETTE[key] ?? `hsl(${hashHue(key)}, 60%, 75%)`;
+}
diff --git a/src/utils/historicalDate.test.ts b/src/utils/historicalDate.test.ts
new file mode 100644
index 0000000..fd1b57e
--- /dev/null
+++ b/src/utils/historicalDate.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect } from 'vitest';
+import { daysInMonth, toHistoricalDate, fromHistoricalDate, clampDay } from './historicalDate';
+
+describe('daysInMonth', () => {
+    it('handles February leap years (Gregorian rule)', () => {
+        expect(daysInMonth(2000, 2)).toBe(29); // divisible by 400
+        expect(daysInMonth(1900, 2)).toBe(28); // divisible by 100, not 400
+        expect(daysInMonth(2004, 2)).toBe(29);
+        expect(daysInMonth(2001, 2)).toBe(28);
+    });
+
+    it('handles 30- and 31-day months', () => {
+        expect(daysInMonth(2026, 4)).toBe(30);
+        expect(daysInMonth(2026, 12)).toBe(31);
+    });
+});
+
+describe('toHistoricalDate / fromHistoricalDate', () => {
+    it('passes CE years through unchanged', () => {
+        expect(toHistoricalDate({ year: 1700, month: 3, day: 22, era: 'CE' })).toEqual({ year: 1700, month: 3, day: 22 });
+    });
+
+    it('maps BCE onto astronomical numbering (1 BCE = 0)', () => {
+        expect(toHistoricalDate({ year: 1, month: 1, day: 1, era: 'BCE' })).toEqual({ year: 0, month: 1, day: 1 });
+        expect(toHistoricalDate({ year: 44, month: 3, day: 15, era: 'BCE' })).toEqual({ year: -43, month: 3, day: 15 });
+    });
+
+    it('round-trips both directions', () => {
+        for (const d of [
+            { year: 2026, month: 6, day: 18, era: 'CE' as const },
+            { year: 1, month: 1, day: 1, era: 'BCE' as const },
+            { year: 44, month: 3, day: 15, era: 'BCE' as const },
+        ]) {
+            expect(fromHistoricalDate(toHistoricalDate(d))).toEqual(d);
+        }
+    });
+});
+
+describe('clampDay', () => {
+    it('clamps a day past the end of the month', () => {
+        expect(clampDay({ year: 2025, month: 2, day: 31, era: 'CE' })).toMatchObject({ month: 2, day: 28 });
+        expect(clampDay({ year: 2024, month: 2, day: 31, era: 'CE' })).toMatchObject({ day: 29 });
+    });
+
+    it('leaves a valid day alone', () => {
+        expect(clampDay({ year: 2026, month: 6, day: 15, era: 'CE' })).toMatchObject({ day: 15 });
+    });
+});
diff --git a/src/utils/historicalDate.ts b/src/utils/historicalDate.ts
new file mode 100644
index 0000000..cf7dceb
--- /dev/null
+++ b/src/utils/historicalDate.ts
@@ -0,0 +1,47 @@
+// Bridge between the date-picker UI and the engine's HistoricalDate.
+//
+// The picker works in the way people actually say dates — a positive year plus
+// a BCE/CE choice — while the engine uses astronomical year numbering
+// (year 0 = 1 BCE). These helpers convert between the two and validate ranges.
+
+import type { HistoricalDate } from '../types';
+
+export type Era = 'CE' | 'BCE';
+
+/** A date as entered in the picker: a positive year plus an era. */
+export interface PickerDate {
+    year: number; // >= 1
+    month: number; // 1–12
+    day: number; // 1–31
+    era: Era;
+}
+
+/** Days in a proleptic-Gregorian month (astronomical year numbering). */
+export function daysInMonth(year: number, month: number): number {
+    if (month === 2) {
+        const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
+        return leap ? 29 : 28;
+    }
+    return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 31;
+}
+
+/** Convert a picker date to the engine's astronomical HistoricalDate. */
+export function toHistoricalDate({ year, month, day, era }: PickerDate): HistoricalDate {
+    // BCE uses astronomical numbering: 1 BCE = 0, 2 BCE = -1, …
+    const astronomicalYear = era === 'BCE' ? 1 - year : year;
+    return { year: astronomicalYear, month, day };
+}
+
+/** Convert an engine HistoricalDate back to picker form. */
+export function fromHistoricalDate({ year, month, day }: HistoricalDate): PickerDate {
+    return year > 0
+        ? { year, month, day, era: 'CE' }
+        : { year: 1 - year, month, day, era: 'BCE' };
+}
+
+/** Clamp a picker date to a valid day-of-month (e.g. after changing month). */
+export function clampDay(date: PickerDate): PickerDate {
+    const astronomicalYear = date.era === 'BCE' ? 1 - date.year : date.year;
+    const max = daysInMonth(astronomicalYear, date.month);
+    return date.day > max ? { ...date, day: max } : date;
+}
diff --git a/src/utils/report.ts b/src/utils/report.ts
index eb79dc5..201d17a 100644
--- a/src/utils/report.ts
+++ b/src/utils/report.ts
@@ -8,6 +8,11 @@
 
 export const REPO_URL = 'https://gitlab.com/PavelSafronov/what-year-is-it';
 
+/** Link to a repo-relative file on the default branch (for data-source links). */
+export function repoFileUrl(path: string): string {
+    return `${REPO_URL}/-/blob/main/${path}`;
+}
+
 export const SERVICE_DESK_EMAIL =
     'contact-project+pavelsafronov-what-year-is-it-80432471-issue-@incoming.gitlab.com';
 
@@ -18,6 +23,7 @@ const MAX_STACK = 1000;
 
 export interface ReportContext {
     picked?: { lat: number; lng: number } | null;
+    date?: string;
 }
 
 let context: ReportContext = {};
@@ -74,16 +80,21 @@ function envLines(): string[] {
     return lines;
 }
 
-function stateLines(): string[] {
+function stateLines(ctx: ReportContext): string[] {
     const lines: string[] = [];
-    if (context.picked) {
-        lines.push(`Picked location: ${context.picked.lat.toFixed(4)}, ${context.picked.lng.toFixed(4)}`);
+    if (ctx.picked) {
+        lines.push(`Picked location: ${ctx.picked.lat.toFixed(4)}, ${ctx.picked.lng.toFixed(4)}`);
     }
+    if (ctx.date) lines.push(`Date: ${ctx.date}`);
     return lines;
 }
 
-/** Build the plain-text report body. Pass an error for crash reports. */
-export function buildReportBody(error?: unknown): string {
+/**
+ * Build the plain-text report body. Pass an error for crash reports. `ctx`
+ * defaults to the module context (used by the async global handlers); the UI
+ * passes the live context explicitly so render-time links aren't stale.
+ */
+export function buildReportBody(error?: unknown, ctx: ReportContext = context): string {
     const sections: string[] = [];
 
     if (error) {
@@ -95,7 +106,7 @@ export function buildReportBody(error?: unknown): string {
         sections.push('Describe the problem:\n\n\n');
     }
 
-    const state = stateLines();
+    const state = stateLines(ctx);
     if (state.length) sections.push(`App state:\n${state.join('\n')}`);
     sections.push(`Environment:\n${envLines().join('\n')}`);
 
@@ -105,8 +116,8 @@ export function buildReportBody(error?: unknown): string {
 }
 
 /** A `mailto:` href to the service desk, prefilled with context. */
-export function reportMailtoHref(error?: unknown): string {
+export function reportMailtoHref(error?: unknown, ctx: ReportContext = context): string {
     const subject = `[what-year-is-it] ${error ? 'Error report' : 'Issue report'}`;
-    const body = buildReportBody(error);
+    const body = buildReportBody(error, ctx);
     return `mailto:${SERVICE_DESK_EMAIL}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
 }
diff --git a/src/utils/resolve.test.ts b/src/utils/resolve.test.ts
index a5c1a95..10402f9 100644
--- a/src/utils/resolve.test.ts
+++ b/src/utils/resolve.test.ts
@@ -18,6 +18,7 @@ const square: Geometry = {
 const regions: CalendarRegion[] = [
     {
         region: { name: 'Testland', geo: square },
+        sourceFile: 'src/data/calendars/testland.json',
         eras: [
             { name: 'Modern', start: '1750-01-01', end: null, calendar: 'Gregorian', usedBy: 'settlers' },
             { name: 'Old Ways', start: null, end: '1750-01-01', calendar: 'Algonquian lunar calendar', usedBy: 'locals' },
@@ -48,6 +49,7 @@ describe('resolve', () => {
         expect(missReason).toBeUndefined();
         expect(answers).toHaveLength(1);
         expect(answers[0].era.name).toBe('Modern');
+        expect(answers[0].sourceFile).toBe('src/data/calendars/testland.json');
         expect(answers[0].conversion).toMatchObject({ system: 'Gregorian', localYearLabel: '1900', convertible: true });
     });
 
diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts
index 16fd529..31b9ae1 100644
--- a/src/utils/resolve.ts
+++ b/src/utils/resolve.ts
@@ -19,9 +19,9 @@ export function resolve(
     if (matched.length === 0) return { answers: [], missReason: 'no-region' };
 
     const answers: Answer[] = [];
-    for (const { region, eras } of matched) {
+    for (const { region, eras, sourceFile } of matched) {
         for (const era of findEras(date, eras)) {
-            answers.push({ region, era, conversion: convert(date, era.calendar) });
+            answers.push({ region, era, conversion: convert(date, era.calendar), sourceFile });
         }
     }
 
diff --git a/src/utils/urlState.test.ts b/src/utils/urlState.test.ts
new file mode 100644
index 0000000..a64876f
--- /dev/null
+++ b/src/utils/urlState.test.ts
@@ -0,0 +1,32 @@
+import { describe, it, expect } from 'vitest';
+import { parseInitialState } from './urlState';
+
+describe('parseInitialState', () => {
+    it('parses a full location + date', () => {
+        expect(parseInitialState('?lat=42.36&lng=-71.06&year=1700&month=4&day=1')).toEqual({
+            picked: { lat: 42.36, lng: -71.06 },
+            date: { year: 1700, month: 4, day: 1 },
+        });
+    });
+
+    it('maps era=BCE to astronomical numbering', () => {
+        expect(parseInitialState('?year=44&month=3&day=15&era=BCE').date).toEqual({ year: -43, month: 3, day: 15 });
+    });
+
+    it('parses an optional zoom', () => {
+        expect(parseInitialState('?lat=1&lng=2&zoom=11').zoom).toBe(11);
+        expect(parseInitialState('?lat=1&lng=2').zoom).toBeUndefined();
+    });
+
+    it('ignores a partial location', () => {
+        expect(parseInitialState('?lat=42.36').picked).toBeUndefined();
+    });
+
+    it('ignores malformed numbers', () => {
+        expect(parseInitialState('?lat=abc&lng=2')).toEqual({});
+    });
+
+    it('returns empty for no params', () => {
+        expect(parseInitialState('')).toEqual({});
+    });
+});
diff --git a/src/utils/urlState.ts b/src/utils/urlState.ts
new file mode 100644
index 0000000..b524b0c
--- /dev/null
+++ b/src/utils/urlState.ts
@@ -0,0 +1,43 @@
+// Initial state from URL query params.
+//
+// Doubles as a shareable-link feature and the deterministic "force a state" hook
+// the screenshot tooling needs (see plan.md). Example:
+//   ?lat=42.36&lng=-71.06&year=1700&month=4&day=1&era=CE
+// All params are optional; a missing or malformed group is simply ignored.
+
+import type { HistoricalDate } from '../types';
+import { toHistoricalDate } from './historicalDate';
+
+export interface InitialState {
+    picked?: { lat: number; lng: number };
+    date?: HistoricalDate;
+    zoom?: number;
+}
+
+function toFloat(value: string | null): number | null {
+    if (value === null || value.trim() === '') return null;
+    const n = Number(value);
+    return Number.isFinite(n) ? n : null;
+}
+
+export function parseInitialState(search: string): InitialState {
+    const params = new URLSearchParams(search);
+    const state: InitialState = {};
+
+    const lat = toFloat(params.get('lat'));
+    const lng = toFloat(params.get('lng'));
+    if (lat !== null && lng !== null) state.picked = { lat, lng };
+
+    const zoom = toFloat(params.get('zoom'));
+    if (zoom !== null) state.zoom = zoom;
+
+    const year = toFloat(params.get('year'));
+    const month = toFloat(params.get('month'));
+    const day = toFloat(params.get('day'));
+    if (year !== null && month !== null && day !== null) {
+        const era = (params.get('era') ?? 'CE').toUpperCase() === 'BCE' ? 'BCE' : 'CE';
+        state.date = toHistoricalDate({ year, month, day, era });
+    }
+
+    return state;
+}