fuzzyworld

when-am-i / commit

feat: timeline date picker with era-color year bands

4014e5e06f342441059d5ec0befb81439d92a1cb · Pavel Safronov · 2026-06-18 16:48:28 -0700 · parents 34c4dd59f826

feat: timeline date picker with era-color year bands

Replace the month/day/year selects with a timeline picker:

- A day-of-year slider (month-initial ticks) for month + day.
- A year scrollbar from 2000 BCE to the present (crosses year 0 into BCE),
  with the current year as a playhead. Arrow keys step one year.
- The year track is shaded by the picked region's calendars over time — each
  era painted in its calendar's color with a small legend — so you can see at a
  glance when (and to what) the local calendar changed.

New pure helpers (daysInYear / dayOfYear / fromDayOfYear) are unit-tested; the
PickerDate contract is unchanged so the engine and App data flow are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 src/App.tsx                      |  25 ++++-
 src/components/DatePicker.tsx    | 199 +++++++++++++++++++++++++++------------
 src/index.css                    |  37 ++++++++
 src/utils/historicalDate.test.ts |  36 ++++++-
 src/utils/historicalDate.ts      |  33 ++++++-
 5 files changed, 262 insertions(+), 68 deletions(-)

diff --git a/src/App.tsx b/src/App.tsx
index 58130c1..a881d9e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,12 +1,14 @@
 import calendar from './assets/calendar.png';
 import React, { useEffect, useMemo, useState } from 'react';
 import { MapPicker, type MapOverlay } from './components/MapPicker';
-import { DatePicker } from './components/DatePicker';
+import { DatePicker, type YearBand } from './components/DatePicker';
 import { ResultPanel } from './components/ResultPanel';
 import { Attribution } from './components/Attribution';
 import { regions } from './data';
 import { resolve } from './utils/resolve';
 import { findEras } from './utils/eras';
+import { findRegions } from './utils/geo';
+import { parseGregorianDate } from './utils/julianDay';
 import { calendarColor } from './utils/calendarColor';
 import {
     fromHistoricalDate,
@@ -26,7 +28,7 @@ const initial = parseInitialState(typeof window !== 'undefined' ? window.locatio
 const now = new Date();
 const defaultDate: PickerDate = initial.date
     ? fromHistoricalDate(initial.date)
-    : { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), era: 'CE' };
+    : { year: 1700, month: now.getMonth() + 1, day: now.getDate(), era: 'CE' };
 
 const sectionLabel: React.CSSProperties = { margin: '0 0 4px', fontSize: '0.85em', fontWeight: 600, color: '#555' };
 
@@ -63,6 +65,23 @@ function App() {
         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 [];
+        const bands: YearBand[] = [];
+        for (const cr of findRegions(picked.lat, picked.lng, regions)) {
+            for (const era of cr.eras) {
+                bands.push({
+                    start: era.start ? parseGregorianDate(era.start).year : -Infinity,
+                    end: era.end ? parseGregorianDate(era.end).year : Infinity,
+                    color: calendarColor(era.calendar),
+                    calendar: era.calendar,
+                });
+            }
+        }
+        return bands;
+    }, [picked]);
+
     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})` };
@@ -107,7 +126,7 @@ function App() {
             )}
 
             <p style={sectionLabel}>When?</p>
-            <DatePicker value={pickerDate} onChange={setPickerDate} />
+            <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} />
 
             <p style={sectionLabel}>Where?</p>
             <MapPicker
diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx
index 135935c..9c11f6e 100644
--- a/src/components/DatePicker.tsx
+++ b/src/components/DatePicker.tsx
@@ -1,83 +1,162 @@
 import React from 'react';
-import { clampDay, daysInMonth, type Era, type PickerDate } from '../utils/historicalDate';
+import {
+    daysInYear,
+    dayOfYear,
+    fromDayOfYear,
+    fromHistoricalDate,
+    toHistoricalDate,
+    type PickerDate,
+} from '../utils/historicalDate';
+
+/** A stretch of the year axis using one calendar, for the colored track bands. */
+export interface YearBand {
+    /** Astronomical years; ±Infinity for open-ended eras. */
+    start: number;
+    end: number;
+    color: string;
+    calendar: string;
+}
 
 interface DatePickerProps {
     value: PickerDate;
     onChange: (next: PickerDate) => void;
+    /** Era bands for the picked region, shown along the year track. */
+    yearBands: YearBand[];
 }
 
 const MONTHS = [
     'January', 'February', 'March', 'April', 'May', 'June',
     'July', 'August', 'September', 'October', 'November', 'December',
 ];
+const MONTH_INITIALS = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'];
+
+// Year axis: 2000 BCE (astronomical -1999) to the present.
+const MIN_YEAR = -1999;
+const MAX_YEAR = new Date().getFullYear();
+
+function yearLabel(astronomicalYear: number): string {
+    return astronomicalYear > 0 ? `${astronomicalYear} CE` : `${1 - astronomicalYear} BCE`;
+}
+
+function pct(year: number): number {
+    const clamped = Math.max(MIN_YEAR, Math.min(MAX_YEAR, year));
+    return ((clamped - MIN_YEAR) / (MAX_YEAR - MIN_YEAR)) * 100;
+}
+
+const barLabel: React.CSSProperties = { margin: 0, fontSize: '0.8em', color: '#666' };
+const rangeStyle: React.CSSProperties = { width: '100%', margin: 0 };
+
+const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands }) => {
+    const astroYear = toHistoricalDate(value).year;
+    const doy = dayOfYear(astroYear, value.month, value.day);
+    const yearDays = daysInYear(astroYear);
 
-const fieldStyle: React.CSSProperties = { padding: '6px 8px', fontSize: '1em' };
+    const emitYear = (nextAstroYear: number) => {
+        const { month, day } = fromDayOfYear(nextAstroYear, doy);
+        onChange(fromHistoricalDate({ year: nextAstroYear, month, day }));
+    };
 
-const DatePicker: React.FC<DatePickerProps> = ({ value, onChange }) => {
-    const astronomicalYear = value.era === 'BCE' ? 1 - value.year : value.year;
-    const maxDay = daysInMonth(astronomicalYear, value.month);
+    const emitDoy = (nextDoy: number) => {
+        const { month, day } = fromDayOfYear(astroYear, nextDoy);
+        onChange(fromHistoricalDate({ year: astroYear, month, day }));
+    };
 
-    // 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 }));
+    // Unique calendars across the bands, for a small legend.
+    const bandLegend = [...new Map(yearBands.map((b) => [b.calendar, b.color])).entries()];
 
     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>
+        <div style={{ margin: '12px 0', maxWidth: 560 }}>
+            <div style={{ fontSize: '1.3em', fontWeight: 700, marginBottom: 12 }}>
+                {MONTHS[value.month - 1]} {value.day}, {yearLabel(astroYear)}
+            </div>
 
-            <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>
+            {/* Day-of-year bar */}
+            <p style={barLabel}>Day of year</p>
+            <input
+                type="range"
+                style={rangeStyle}
+                min={1}
+                max={yearDays}
+                step={1}
+                value={doy}
+                onChange={(e) => emitDoy(Number(e.target.value))}
+                aria-label="Day of year"
+                aria-valuetext={`${MONTHS[value.month - 1]} ${value.day}`}
+            />
+            <div style={{ position: 'relative', height: 16, marginBottom: 16 }}>
+                {MONTH_INITIALS.map((initial, i) => {
+                    const left = ((dayOfYear(astroYear, i + 1, 1) - 1) / yearDays) * 100;
+                    return (
+                        <span
+                            key={i}
+                            style={{ position: 'absolute', left: `${left}%`, fontSize: '0.7em', color: '#999' }}
+                        >
+                            {initial}
+                        </span>
+                    );
+                })}
+            </div>
 
-            <label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: '0.85em', color: '#555' }}>
-                Year
+            {/* Year bar with era-color bands behind the track */}
+            <p style={barLabel}>Year</p>
+            <div style={{ position: 'relative', height: 28 }}>
+                <div
+                    style={{
+                        position: 'absolute',
+                        inset: 0,
+                        background: '#e9ecef',
+                        borderRadius: 3,
+                        overflow: 'hidden',
+                    }}
+                >
+                    {yearBands.map((band, i) => {
+                        const left = pct(band.start);
+                        const width = pct(band.end) - left;
+                        return (
+                            <div
+                                key={i}
+                                title={band.calendar}
+                                style={{
+                                    position: 'absolute',
+                                    left: `${left}%`,
+                                    width: `${width}%`,
+                                    top: 0,
+                                    bottom: 0,
+                                    background: band.color,
+                                    opacity: 0.75,
+                                }}
+                            />
+                        );
+                    })}
+                </div>
                 <input
-                    style={{ ...fieldStyle, width: 96 }}
-                    type="number"
-                    min={1}
-                    value={value.year}
-                    onChange={(e) => update({ year: Math.max(1, Number(e.target.value)) })}
+                    className="year-range"
+                    style={{ position: 'relative', zIndex: 1 }}
+                    type="range"
+                    min={MIN_YEAR}
+                    max={MAX_YEAR}
+                    step={1}
+                    value={astroYear}
+                    onChange={(e) => emitYear(Number(e.target.value))}
+                    aria-label="Year"
+                    aria-valuetext={yearLabel(astroYear)}
                 />
-            </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>
+            <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.7em', color: '#999' }}>
+                <span>{yearLabel(MIN_YEAR)}</span>
+                <span>{yearLabel(MAX_YEAR)}</span>
+            </div>
+
+            {bandLegend.length > 0 && (
+                <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 6, fontSize: '0.75em', color: '#555' }}>
+                    {bandLegend.map(([cal, color]) => (
+                        <span key={cal} style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
+                            <span aria-hidden style={{ width: 11, height: 11, background: color, border: '1px solid #ccc', borderRadius: 2 }} />
+                            {cal}
+                        </span>
+                    ))}
+                </div>
+            )}
         </div>
     );
 };
diff --git a/src/index.css b/src/index.css
index 3a87d0b..5672d0e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -5,4 +5,41 @@ body {
     margin: 0;
     font-family: system-ui, sans-serif;
     background: #f8f9fa;
+}
+
+/* Year slider: transparent track so the era-color bands behind it show
+   through, with a tall "playhead" thumb. */
+.year-range {
+    -webkit-appearance: none;
+    appearance: none;
+    width: 100%;
+    height: 28px;
+    margin: 0;
+    background: transparent;
+    cursor: pointer;
+}
+.year-range::-webkit-slider-runnable-track {
+    height: 28px;
+    background: transparent;
+}
+.year-range::-moz-range-track {
+    height: 28px;
+    background: transparent;
+}
+.year-range::-webkit-slider-thumb {
+    -webkit-appearance: none;
+    appearance: none;
+    width: 4px;
+    height: 36px;
+    margin-top: -4px;
+    background: #1a1a1a;
+    border: 1px solid #fff;
+    border-radius: 2px;
+}
+.year-range::-moz-range-thumb {
+    width: 4px;
+    height: 36px;
+    background: #1a1a1a;
+    border: 1px solid #fff;
+    border-radius: 2px;
 }
\ No newline at end of file
diff --git a/src/utils/historicalDate.test.ts b/src/utils/historicalDate.test.ts
index fd1b57e..f9a62bf 100644
--- a/src/utils/historicalDate.test.ts
+++ b/src/utils/historicalDate.test.ts
@@ -1,5 +1,13 @@
 import { describe, it, expect } from 'vitest';
-import { daysInMonth, toHistoricalDate, fromHistoricalDate, clampDay } from './historicalDate';
+import {
+    daysInMonth,
+    daysInYear,
+    dayOfYear,
+    fromDayOfYear,
+    toHistoricalDate,
+    fromHistoricalDate,
+    clampDay,
+} from './historicalDate';
 
 describe('daysInMonth', () => {
     it('handles February leap years (Gregorian rule)', () => {
@@ -15,6 +23,32 @@ describe('daysInMonth', () => {
     });
 });
 
+describe('day-of-year helpers', () => {
+    it('daysInYear handles leap years', () => {
+        expect(daysInYear(2000)).toBe(366);
+        expect(daysInYear(1900)).toBe(365);
+        expect(daysInYear(2001)).toBe(365);
+    });
+
+    it('dayOfYear counts from Jan 1', () => {
+        expect(dayOfYear(2001, 1, 1)).toBe(1);
+        expect(dayOfYear(2001, 3, 1)).toBe(60); // 31 + 28 + 1
+        expect(dayOfYear(2000, 3, 1)).toBe(61); // leap: 31 + 29 + 1
+        expect(dayOfYear(2001, 12, 31)).toBe(365);
+    });
+
+    it('fromDayOfYear inverts dayOfYear', () => {
+        for (const [year, m, d] of [[2001, 3, 1], [2000, 3, 1], [2026, 6, 18], [1999, 12, 31]] as const) {
+            expect(fromDayOfYear(year, dayOfYear(year, m, d))).toEqual({ month: m, day: d });
+        }
+    });
+
+    it('fromDayOfYear clamps out-of-range input', () => {
+        expect(fromDayOfYear(2001, 999)).toEqual({ month: 12, day: 31 });
+        expect(fromDayOfYear(2001, 0)).toEqual({ month: 1, day: 1 });
+    });
+});
+
 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 });
diff --git a/src/utils/historicalDate.ts b/src/utils/historicalDate.ts
index cf7dceb..050ccb5 100644
--- a/src/utils/historicalDate.ts
+++ b/src/utils/historicalDate.ts
@@ -16,15 +16,40 @@ export interface PickerDate {
     era: Era;
 }
 
+/** Whether a proleptic-Gregorian year is a leap year. */
+export function isLeapYear(year: number): boolean {
+    return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
+}
+
 /** 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;
-    }
+    if (month === 2) return isLeapYear(year) ? 29 : 28;
     return [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 31;
 }
 
+/** Days in a proleptic-Gregorian year (365 or 366). */
+export function daysInYear(year: number): number {
+    return isLeapYear(year) ? 366 : 365;
+}
+
+/** 1-based ordinal day within the year for a (month, day). */
+export function dayOfYear(year: number, month: number, day: number): number {
+    let doy = day;
+    for (let m = 1; m < month; m++) doy += daysInMonth(year, m);
+    return doy;
+}
+
+/** Convert a 1-based ordinal day-of-year back to a (month, day), clamped. */
+export function fromDayOfYear(year: number, doy: number): { month: number; day: number } {
+    let remaining = Math.max(1, Math.min(Math.round(doy), daysInYear(year)));
+    let month = 1;
+    while (month < 12 && remaining > daysInMonth(year, month)) {
+        remaining -= daysInMonth(year, month);
+        month++;
+    }
+    return { month, day: remaining };
+}
+
 /** 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, …