when-am-i / commit
feat: interactive UI — date picker, answer panel, map overlays, data links
.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’re a time traveler who lands somewhere in history. You stop a local and ask: <em>“What year is it?”</em> Their answer depends on where - you are — and who you ask. Pick a spot on the map to begin. + you are — 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='© 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’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’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’s what you’d hear if you asked + “What year is it?”: + </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; +}