fuzzyworld

when-am-i / commit

Show calendar-era bands on the day-of-year scrollbar with clickable changeover ticks

68dcf3edc63ea36aa28cc739e044c1ce432d892c · Pavel Safronov · 2026-06-24 21:33:55 -0700 · parents 3765de0e189e

Claude Code usage: this commit ~43.0K out / ~3.2M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 5 tally 100 in / 43.0K out / 3.2M cached · usage by commit and session

Show calendar-era bands on the day-of-year scrollbar with clickable changeover ticks

The day-of-year bar now shows colored calendar bands (matching the year track)
for the selected year, splitting at any mid-year changeover so you can see
*when in the year* the calendar changed. Changeover dates appear as clickable
tick labels; ticks that land close together on the bar are grouped side-by-side
on one line instead of stacking vertically. Month initials are hidden when
changeover ticks are present.

Usage: 0.1K in / 43.0K out / 3.2M cached (session tally)
rtk:   2.4M saved, 94.3% over 814 cmds (global/lifetime)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 README.md                                          |   8 +-
 screenshots/app/01-landing.png                     | Bin 297586 -> 297492 bytes
 screenshots/app/02-boston-1650.png                 | Bin 1196180 -> 1196456 bytes
 screenshots/app/03-boston-1752.png                 | Bin 0 -> 1199007 bytes
 screenshots/app/03-boston-1800.png                 | Bin 1190074 -> 0 bytes
 screenshots/app/04-boston-1800.png                 | Bin 0 -> 1190448 bytes
 .../calendars/aztec/01-aztec-xiuhp-hualli.png      | Bin 868014 -> 868143 bytes
 .../aztec/02-aztec-mexica-year-bearer.png          | Bin 868014 -> 868143 bytes
 screenshots/calendars/aztec/03-t-nalp-hualli.png   | Bin 868934 -> 869081 bytes
 .../china/01-chinese-sexagenary-cycle.png          | Bin 584041 -> 584043 bytes
 .../calendars/colonial-america/01-julian.png       | Bin 416532 -> 416457 bytes
 .../calendars/colonial-america/02-gregorian.png    | Bin 400139 -> 400240 bytes
 screenshots/calendars/ethiopia/01-ethiopian.png    | Bin 487790 -> 487786 bytes
 screenshots/calendars/india/01-indian.png          | Bin 465215 -> 465219 bytes
 screenshots/calendars/iran/01-islamic.png          | Bin 514650 -> 514649 bytes
 screenshots/calendars/iran/02-persian.png          | Bin 621930 -> 621927 bytes
 .../japan/01-japanese-lunisolar-calendar.png       | Bin 450820 -> 450819 bytes
 screenshots/calendars/japan/02-japanese.png        | Bin 429817 -> 429820 bytes
 screenshots/calendars/judea/01-hebrew.png          | Bin 890624 -> 890620 bytes
 screenshots/calendars/judea/02-ab-urbe-condita.png | Bin 1128153 -> 1128186 bytes
 screenshots/calendars/judea/03-seleucid-era.png    | Bin 1128473 -> 1128522 bytes
 .../korea/01-korean-lunisolar-calendar.png         | Bin 755465 -> 755467 bytes
 screenshots/calendars/korea/02-gregorian.png       | Bin 682457 -> 682404 bytes
 screenshots/calendars/korea/03-gregorian.png       | Bin 680413 -> 680356 bytes
 screenshots/calendars/korea/04-gregorian.png       | Bin 655458 -> 655400 bytes
 screenshots/calendars/korea/05-gregorian.png       | Bin 661989 -> 661948 bytes
 .../maori/01-maramataka-m-ori-lunar-calendar.png   | Bin 444079 -> 444075 bytes
 .../massachusett/01-algonquian-lunar-calendar.png  | Bin 1082019 -> 1081968 bytes
 screenshots/calendars/maya/01-maya-long-count.png  | Bin 907023 -> 907170 bytes
 .../maya/02-tzolk-in-and-haab-calendar-round.png   | Bin 694426 -> 694533 bytes
 screenshots/calendars/north-korea/01-dangi.png     | Bin 752334 -> 752302 bytes
 screenshots/calendars/north-korea/02-juche.png     | Bin 793504 -> 793472 bytes
 screenshots/calendars/north-korea/03-gregorian.png | Bin 745821 -> 745772 bytes
 .../north-sentinel-island/01-none-documented.png   | Bin 508468 -> 508467 bytes
 .../calendars/oregon-country/01-gregorian.png      | Bin 495385 -> 495541 bytes
 screenshots/calendars/ottoman/01-islamic.png       | Bin 500085 -> 500085 bytes
 screenshots/calendars/ottoman/02-rumi.png          | Bin 716632 -> 716646 bytes
 screenshots/calendars/ottoman/03-gregorian.png     | Bin 434544 -> 434499 bytes
 .../revolutionary-france/01-gregorian.png          | Bin 460458 -> 460360 bytes
 .../02-french-republican-calendar.png              | Bin 534635 -> 534629 bytes
 .../03-french-republican-calendar.png              | Bin 452995 -> 455318 bytes
 .../revolutionary-france/04-gregorian.png          | Bin 455844 -> 455799 bytes
 .../calendars/russian-america/01-julian.png        | Bin 490970 -> 490929 bytes
 .../calendars/russian-america/02-gregorian.png     | Bin 474531 -> 474491 bytes
 screenshots/calendars/thailand/01-buddhist.png     | Bin 486772 -> 486755 bytes
 .../vietnam/01-vietnamese-lunisolar-calendar.png   | Bin 718942 -> 718941 bytes
 screenshots/calendars/vietnam/02-gregorian.png     | Bin 707550 -> 707654 bytes
 .../vietnam/03-vietnamese-lunisolar-calendar.png   | Bin 707550 -> 707654 bytes
 screenshots/readme/01-landing.png                  | Bin 229447 -> 229441 bytes
 screenshots/readme/02-example.png                  | Bin 942830 -> 942783 bytes
 scripts/generate-screenshots.mts                   |   7 +-
 scripts/lib/server.mts                             |  11 +-
 src/App.tsx                                        |  68 ++++------
 src/components/DatePicker.tsx                      | 145 ++++++++++++++++++---
 src/utils/timelineBands.test.ts                    |  99 ++++++++++++++
 src/utils/timelineBands.ts                         |  99 ++++++++++++++
 56 files changed, 373 insertions(+), 64 deletions(-)

diff --git a/README.md b/README.md
index f3ce1ab..bff3d09 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,10 @@ when more than one calendar applies at once:
   they're evenly spaced and labelled (e.g. 1954 for Vietnam) instead of crammed
   into recent centuries; dashed lines mark where the calendar actually changes,
   and each year label is **clickable to jump** straight to that date
+- A **day-of-year track** color-coded the same way for the *selected* year, so a
+  changeover that falls mid-year shows up as a color boundary within the year —
+  e.g. for Boston in 1752 it switches from Julian to Gregorian in mid-September
+  (the British calendar reform), with a dashed line on the exact day
 - **Shuffle** button that drops you at a random place and date on a non-default
   (non-Gregorian) calendar — e.g. Chinese, Japanese, Saka, Buddhist, Ethiopian,
   Islamic, Persian, Julian, or a lunar reckoning
@@ -137,7 +141,9 @@ npm run gen:badges -- --check # fail if the block is stale (no write) — for CI
 
 Screenshots are generated with Playwright, which forces deterministic app state
 via URL params (`?lat=&lng=&zoom=&year=&month=&day=&era=`). The scripts reuse a
-running dev server or start one automatically.
+running dev server on port 5173 or start one automatically. Set
+`SCREENSHOT_PORT` to use a different port — handy when another checkout (e.g. a
+parallel workspace) already holds 5173, so you capture *this* code, not theirs.
 
 ```bash
 npm run screenshot                                   # app states → screenshots/app/
diff --git a/screenshots/app/01-landing.png b/screenshots/app/01-landing.png
index 5abf82d..b502a86 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 1ede8ff..f2064f0 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-1752.png b/screenshots/app/03-boston-1752.png
new file mode 100644
index 0000000..042d8b6
Binary files /dev/null and b/screenshots/app/03-boston-1752.png differ
diff --git a/screenshots/app/03-boston-1800.png b/screenshots/app/03-boston-1800.png
deleted file mode 100644
index b4b827a..0000000
Binary files a/screenshots/app/03-boston-1800.png and /dev/null differ
diff --git a/screenshots/app/04-boston-1800.png b/screenshots/app/04-boston-1800.png
new file mode 100644
index 0000000..9070cc6
Binary files /dev/null and b/screenshots/app/04-boston-1800.png differ
diff --git a/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png b/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png
index c40d8db..aa5a787 100644
Binary files a/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png and b/screenshots/calendars/aztec/01-aztec-xiuhp-hualli.png differ
diff --git a/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png b/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png
index c40d8db..aa5a787 100644
Binary files a/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png and b/screenshots/calendars/aztec/02-aztec-mexica-year-bearer.png differ
diff --git a/screenshots/calendars/aztec/03-t-nalp-hualli.png b/screenshots/calendars/aztec/03-t-nalp-hualli.png
index 5d9d605..9793e99 100644
Binary files a/screenshots/calendars/aztec/03-t-nalp-hualli.png and b/screenshots/calendars/aztec/03-t-nalp-hualli.png differ
diff --git a/screenshots/calendars/china/01-chinese-sexagenary-cycle.png b/screenshots/calendars/china/01-chinese-sexagenary-cycle.png
index 1d90cf7..baa9102 100644
Binary files a/screenshots/calendars/china/01-chinese-sexagenary-cycle.png and b/screenshots/calendars/china/01-chinese-sexagenary-cycle.png differ
diff --git a/screenshots/calendars/colonial-america/01-julian.png b/screenshots/calendars/colonial-america/01-julian.png
index 0b9cc45..9a98eb7 100644
Binary files a/screenshots/calendars/colonial-america/01-julian.png and b/screenshots/calendars/colonial-america/01-julian.png differ
diff --git a/screenshots/calendars/colonial-america/02-gregorian.png b/screenshots/calendars/colonial-america/02-gregorian.png
index 85ca4f2..18679c1 100644
Binary files a/screenshots/calendars/colonial-america/02-gregorian.png and b/screenshots/calendars/colonial-america/02-gregorian.png differ
diff --git a/screenshots/calendars/ethiopia/01-ethiopian.png b/screenshots/calendars/ethiopia/01-ethiopian.png
index 22fcb95..07915ef 100644
Binary files a/screenshots/calendars/ethiopia/01-ethiopian.png and b/screenshots/calendars/ethiopia/01-ethiopian.png differ
diff --git a/screenshots/calendars/india/01-indian.png b/screenshots/calendars/india/01-indian.png
index 1479c1a..a377c54 100644
Binary files a/screenshots/calendars/india/01-indian.png and b/screenshots/calendars/india/01-indian.png differ
diff --git a/screenshots/calendars/iran/01-islamic.png b/screenshots/calendars/iran/01-islamic.png
index 8c945bc..4ae9752 100644
Binary files a/screenshots/calendars/iran/01-islamic.png and b/screenshots/calendars/iran/01-islamic.png differ
diff --git a/screenshots/calendars/iran/02-persian.png b/screenshots/calendars/iran/02-persian.png
index 07d6bf7..3e0f262 100644
Binary files a/screenshots/calendars/iran/02-persian.png and b/screenshots/calendars/iran/02-persian.png differ
diff --git a/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png b/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png
index 3cb193c..5ec1895 100644
Binary files a/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png and b/screenshots/calendars/japan/01-japanese-lunisolar-calendar.png differ
diff --git a/screenshots/calendars/japan/02-japanese.png b/screenshots/calendars/japan/02-japanese.png
index 7b903c2..0c15c97 100644
Binary files a/screenshots/calendars/japan/02-japanese.png and b/screenshots/calendars/japan/02-japanese.png differ
diff --git a/screenshots/calendars/judea/01-hebrew.png b/screenshots/calendars/judea/01-hebrew.png
index af0e5b6..03a6ba9 100644
Binary files a/screenshots/calendars/judea/01-hebrew.png and b/screenshots/calendars/judea/01-hebrew.png differ
diff --git a/screenshots/calendars/judea/02-ab-urbe-condita.png b/screenshots/calendars/judea/02-ab-urbe-condita.png
index ee660d9..71cae1e 100644
Binary files a/screenshots/calendars/judea/02-ab-urbe-condita.png and b/screenshots/calendars/judea/02-ab-urbe-condita.png differ
diff --git a/screenshots/calendars/judea/03-seleucid-era.png b/screenshots/calendars/judea/03-seleucid-era.png
index ab272ec..38650ee 100644
Binary files a/screenshots/calendars/judea/03-seleucid-era.png and b/screenshots/calendars/judea/03-seleucid-era.png differ
diff --git a/screenshots/calendars/korea/01-korean-lunisolar-calendar.png b/screenshots/calendars/korea/01-korean-lunisolar-calendar.png
index 4bb6aad..0caba2b 100644
Binary files a/screenshots/calendars/korea/01-korean-lunisolar-calendar.png and b/screenshots/calendars/korea/01-korean-lunisolar-calendar.png differ
diff --git a/screenshots/calendars/korea/02-gregorian.png b/screenshots/calendars/korea/02-gregorian.png
index 5860a2e..22e3352 100644
Binary files a/screenshots/calendars/korea/02-gregorian.png and b/screenshots/calendars/korea/02-gregorian.png differ
diff --git a/screenshots/calendars/korea/03-gregorian.png b/screenshots/calendars/korea/03-gregorian.png
index 2848c8c..b61ac5c 100644
Binary files a/screenshots/calendars/korea/03-gregorian.png and b/screenshots/calendars/korea/03-gregorian.png differ
diff --git a/screenshots/calendars/korea/04-gregorian.png b/screenshots/calendars/korea/04-gregorian.png
index eff1920..f7f940b 100644
Binary files a/screenshots/calendars/korea/04-gregorian.png and b/screenshots/calendars/korea/04-gregorian.png differ
diff --git a/screenshots/calendars/korea/05-gregorian.png b/screenshots/calendars/korea/05-gregorian.png
index 291c873..7611559 100644
Binary files a/screenshots/calendars/korea/05-gregorian.png and b/screenshots/calendars/korea/05-gregorian.png differ
diff --git a/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png b/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png
index 9004b0a..ec96573 100644
Binary files a/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png and b/screenshots/calendars/maori/01-maramataka-m-ori-lunar-calendar.png differ
diff --git a/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png b/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png
index 4799156..1d540b8 100644
Binary files a/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png and b/screenshots/calendars/massachusett/01-algonquian-lunar-calendar.png differ
diff --git a/screenshots/calendars/maya/01-maya-long-count.png b/screenshots/calendars/maya/01-maya-long-count.png
index 658c7af..73a7bde 100644
Binary files a/screenshots/calendars/maya/01-maya-long-count.png and b/screenshots/calendars/maya/01-maya-long-count.png differ
diff --git a/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png b/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png
index 1bc437d..d50812d 100644
Binary files a/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png and b/screenshots/calendars/maya/02-tzolk-in-and-haab-calendar-round.png differ
diff --git a/screenshots/calendars/north-korea/01-dangi.png b/screenshots/calendars/north-korea/01-dangi.png
index 1b96b1c..c21d197 100644
Binary files a/screenshots/calendars/north-korea/01-dangi.png and b/screenshots/calendars/north-korea/01-dangi.png differ
diff --git a/screenshots/calendars/north-korea/02-juche.png b/screenshots/calendars/north-korea/02-juche.png
index 3486f70..1c08e96 100644
Binary files a/screenshots/calendars/north-korea/02-juche.png and b/screenshots/calendars/north-korea/02-juche.png differ
diff --git a/screenshots/calendars/north-korea/03-gregorian.png b/screenshots/calendars/north-korea/03-gregorian.png
index 7adb052..59d6472 100644
Binary files a/screenshots/calendars/north-korea/03-gregorian.png and b/screenshots/calendars/north-korea/03-gregorian.png differ
diff --git a/screenshots/calendars/north-sentinel-island/01-none-documented.png b/screenshots/calendars/north-sentinel-island/01-none-documented.png
index bbaa35a..7d7bbf1 100644
Binary files a/screenshots/calendars/north-sentinel-island/01-none-documented.png and b/screenshots/calendars/north-sentinel-island/01-none-documented.png differ
diff --git a/screenshots/calendars/oregon-country/01-gregorian.png b/screenshots/calendars/oregon-country/01-gregorian.png
index 626df2a..c022eef 100644
Binary files a/screenshots/calendars/oregon-country/01-gregorian.png and b/screenshots/calendars/oregon-country/01-gregorian.png differ
diff --git a/screenshots/calendars/ottoman/01-islamic.png b/screenshots/calendars/ottoman/01-islamic.png
index c5edd86..b36018b 100644
Binary files a/screenshots/calendars/ottoman/01-islamic.png and b/screenshots/calendars/ottoman/01-islamic.png differ
diff --git a/screenshots/calendars/ottoman/02-rumi.png b/screenshots/calendars/ottoman/02-rumi.png
index 3c23660..5891d30 100644
Binary files a/screenshots/calendars/ottoman/02-rumi.png and b/screenshots/calendars/ottoman/02-rumi.png differ
diff --git a/screenshots/calendars/ottoman/03-gregorian.png b/screenshots/calendars/ottoman/03-gregorian.png
index 354b5c8..da1cc51 100644
Binary files a/screenshots/calendars/ottoman/03-gregorian.png and b/screenshots/calendars/ottoman/03-gregorian.png differ
diff --git a/screenshots/calendars/revolutionary-france/01-gregorian.png b/screenshots/calendars/revolutionary-france/01-gregorian.png
index dc5b97e..e64a21d 100644
Binary files a/screenshots/calendars/revolutionary-france/01-gregorian.png and b/screenshots/calendars/revolutionary-france/01-gregorian.png differ
diff --git a/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png b/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png
index 555d34d..7232ce6 100644
Binary files a/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png and b/screenshots/calendars/revolutionary-france/02-french-republican-calendar.png differ
diff --git a/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png b/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png
index b60f80c..a5cdd3a 100644
Binary files a/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png and b/screenshots/calendars/revolutionary-france/03-french-republican-calendar.png differ
diff --git a/screenshots/calendars/revolutionary-france/04-gregorian.png b/screenshots/calendars/revolutionary-france/04-gregorian.png
index c717ac7..44412bb 100644
Binary files a/screenshots/calendars/revolutionary-france/04-gregorian.png and b/screenshots/calendars/revolutionary-france/04-gregorian.png differ
diff --git a/screenshots/calendars/russian-america/01-julian.png b/screenshots/calendars/russian-america/01-julian.png
index ef0543c..88262b6 100644
Binary files a/screenshots/calendars/russian-america/01-julian.png and b/screenshots/calendars/russian-america/01-julian.png differ
diff --git a/screenshots/calendars/russian-america/02-gregorian.png b/screenshots/calendars/russian-america/02-gregorian.png
index bca5488..a415acb 100644
Binary files a/screenshots/calendars/russian-america/02-gregorian.png and b/screenshots/calendars/russian-america/02-gregorian.png differ
diff --git a/screenshots/calendars/thailand/01-buddhist.png b/screenshots/calendars/thailand/01-buddhist.png
index baf8e3e..38974a5 100644
Binary files a/screenshots/calendars/thailand/01-buddhist.png and b/screenshots/calendars/thailand/01-buddhist.png differ
diff --git a/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png b/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png
index f17ff0d..588f798 100644
Binary files a/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png and b/screenshots/calendars/vietnam/01-vietnamese-lunisolar-calendar.png differ
diff --git a/screenshots/calendars/vietnam/02-gregorian.png b/screenshots/calendars/vietnam/02-gregorian.png
index 8ac4d1b..228cfc2 100644
Binary files a/screenshots/calendars/vietnam/02-gregorian.png and b/screenshots/calendars/vietnam/02-gregorian.png differ
diff --git a/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png b/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png
index 8ac4d1b..228cfc2 100644
Binary files a/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png and b/screenshots/calendars/vietnam/03-vietnamese-lunisolar-calendar.png differ
diff --git a/screenshots/readme/01-landing.png b/screenshots/readme/01-landing.png
index 5e4cfa0..e225717 100644
Binary files a/screenshots/readme/01-landing.png and b/screenshots/readme/01-landing.png differ
diff --git a/screenshots/readme/02-example.png b/screenshots/readme/02-example.png
index b1d327b..33a2465 100644
Binary files a/screenshots/readme/02-example.png and b/screenshots/readme/02-example.png differ
diff --git a/scripts/generate-screenshots.mts b/scripts/generate-screenshots.mts
index 40e3698..9b1d598 100644
--- a/scripts/generate-screenshots.mts
+++ b/scripts/generate-screenshots.mts
@@ -19,7 +19,12 @@ const STATES = [
         desc: 'Boston, 1650 — coexistence (Massachusett + colonial Julian)',
     },
     {
-        name: '03-boston-1800',
+        name: '03-boston-1752',
+        query: '?lat=42.32&lng=-71.06&zoom=10&year=1752&month=9&day=20',
+        desc: 'Boston, 1752 — the British Julian→Gregorian switch mid-September (day-of-year track splits)',
+    },
+    {
+        name: '04-boston-1800',
         query: '?lat=42.32&lng=-71.06&zoom=10&year=1800&month=6&day=15',
         desc: 'Boston, 1800 — Gregorian',
     },
diff --git a/scripts/lib/server.mts b/scripts/lib/server.mts
index 0c5ddef..a925557 100644
--- a/scripts/lib/server.mts
+++ b/scripts/lib/server.mts
@@ -8,7 +8,12 @@ import { fileURLToPath } from 'node:url';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 export const REPO_ROOT = path.resolve(__dirname, '../..');
-export const BASE_URL = 'http://127.0.0.1:5173/when-am-i/';
+
+// Port is overridable via SCREENSHOT_PORT so screenshots can be generated
+// against *this* checkout even when another Vite server (e.g. a parallel
+// Conductor workspace) already holds the default 5173. Defaults to 5173.
+const PORT = process.env.SCREENSHOT_PORT ?? '5173';
+export const BASE_URL = `http://127.0.0.1:${PORT}/when-am-i/`;
 
 function isUp(url: string): Promise<boolean> {
     return new Promise((resolve) => {
@@ -36,8 +41,8 @@ export async function startDevServer(): Promise<DevServer> {
         return { stop: () => {} };
     }
 
-    console.log('  🚀  Starting Vite dev server…');
-    const proc: ChildProcess = spawn('npx', ['vite', '--port', '5173', '--strictPort'], {
+    console.log(`  🚀  Starting Vite dev server on :${PORT}…`);
+    const proc: ChildProcess = spawn('npx', ['vite', '--port', PORT, '--strictPort'], {
         cwd: REPO_ROOT,
         stdio: 'ignore',
     });
diff --git a/src/App.tsx b/src/App.tsx
index bb08ec1..74da4f0 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,14 +1,13 @@
 import calendar from './assets/calendar.png';
 import React, { useEffect, useMemo, useRef, useState } from 'react';
 import { MapPicker, type MapOverlay, type Recenter } from './components/MapPicker';
-import { DatePicker, type YearBand } from './components/DatePicker';
+import { DatePicker, type YearBand, type DayBand } from './components/DatePicker';
 import { ResultPanel } from './components/ResultPanel';
 import { Attribution } from './components/Attribution';
 import { regions } from './data';
-import { resolve, MODERN_DEFAULT_START_YEAR } from './utils/resolve';
+import { resolve } from './utils/resolve';
 import { findEras } from './utils/eras';
-import { findRegions } from './utils/geo';
-import { parseGregorianDate } from './utils/julianDay';
+import { yearBandsForPoint, dayBandsForPoint } from './utils/timelineBands';
 import { deriveMinYear } from './utils/dateRange';
 import { calendarColorScale } from './utils/calendarColor';
 import { nearestCity } from './utils/nearestCity';
@@ -117,41 +116,30 @@ function App() {
     }, [date]);
 
     // Era bands for the year scrollbar: the picked region's calendars over time.
-    const yearBands = useMemo<YearBand[]>(() => {
-        if (!picked) return [];
-        const bands: YearBand[] = [];
-        let suppressesModernDefault = false;
-        for (const cr of findRegions(picked.lat, picked.lng, regions)) {
-            for (const era of cr.eras) {
-                const end = era.end ? parseGregorianDate(era.end).year : Infinity;
-                bands.push({
-                    start: era.start ? parseGregorianDate(era.start).year : -Infinity,
-                    end,
-                    color: colorFor(era.calendar),
-                    calendar: era.calendar,
-                });
-                // Mirror resolve()'s suppressModernDefault: an era opting out
-                // (e.g. North Sentinel Island) blocks the global Gregorian band
-                // too, not just the text answer — only if it reaches modern times.
-                if (era.suppressModernDefault && end >= MODERN_DEFAULT_START_YEAR) suppressesModernDefault = true;
-            }
-        }
-        // Mirror resolve()'s modern global Gregorian default on the timeline,
-        // unless a region already supplies a Gregorian band reaching modern
-        // times, or an era explicitly suppresses the default here.
-        const hasModernGregorian = bands.some(
-            (b) => b.calendar === 'Gregorian' && b.end >= MODERN_DEFAULT_START_YEAR,
-        );
-        if (!hasModernGregorian && !suppressesModernDefault) {
-            bands.push({
-                start: MODERN_DEFAULT_START_YEAR,
-                end: Infinity,
-                color: colorFor('Gregorian'),
-                calendar: 'Gregorian',
-            });
-        }
-        return bands;
-    }, [picked]);
+    const yearBands = useMemo<YearBand[]>(
+        () =>
+            picked
+                ? yearBandsForPoint(picked.lat, picked.lng, regions).map((b) => ({
+                      ...b,
+                      color: colorFor(b.calendar),
+                  }))
+                : [],
+        [picked],
+    );
+
+    // Era bands for the day-of-year scrollbar: the same calendars clipped to the
+    // selected year, so a mid-year changeover (e.g. a Gregorian adoption date)
+    // shows up as a color boundary within the year, not just between years.
+    const dayBands = useMemo<DayBand[]>(
+        () =>
+            picked
+                ? dayBandsForPoint(picked.lat, picked.lng, regions, date.year).map((b) => ({
+                      ...b,
+                      color: colorFor(b.calendar),
+                  }))
+                : [],
+        [picked, date.year],
+    );
 
     const reportContext = useMemo<ReportContext>(() => {
         const dateText = date.year > 0 ? `${date.year} CE` : `${1 - date.year} BCE`;
@@ -234,7 +222,7 @@ function App() {
             <div className="app-cols">
                 <div>
                     <p style={sectionLabel}>When?</p>
-                    <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} minYear={minYear} />
+                    <DatePicker value={pickerDate} onChange={setPickerDate} yearBands={yearBands} dayBands={dayBands} minYear={minYear} />
 
                     <p style={sectionLabel}>Where?</p>
                     {picked && (
diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx
index 3d649c2..7a7c239 100644
--- a/src/components/DatePicker.tsx
+++ b/src/components/DatePicker.tsx
@@ -19,11 +19,21 @@ export interface YearBand {
     calendar: string;
 }
 
+/** A stretch of the day-of-year axis using one calendar (1-based, inclusive). */
+export interface DayBand {
+    startDoy: number;
+    endDoy: 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[];
+    /** Era bands clipped to the selected year, shown along the day-of-year track. */
+    dayBands: DayBand[];
     /** Earliest year the slider reaches; defaults to FALLBACK_MIN_YEAR. */
     minYear?: number;
 }
@@ -67,11 +77,47 @@ const trackStyle: React.CSSProperties = {
     overflow: 'hidden',
 };
 
-const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, minYear = FALLBACK_MIN_YEAR }) => {
+const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, dayBands, minYear = FALLBACK_MIN_YEAR }) => {
     const astroYear = toHistoricalDate(value).year;
     const doy = dayOfYear(astroYear, value.month, value.day);
     const yearDays = daysInYear(astroYear);
 
+    // Day-of-year track: a day d (1-based) occupies the slice
+    // [(d-1)/yearDays, d/yearDays), matching the month-initial placement below,
+    // so a band covering days [startDoy, endDoy] spans those slices exactly.
+    const dayPct = (doy0: number) => (doy0 / yearDays) * 100;
+    // Pack any concurrent calendars (parallel reckonings in the same year) into
+    // lanes, as the year bar does. endDoy is inclusive ⇒ exclusive edge is +1.
+    const { lanes: dayLanes, count: dayLaneCount } = assignLanes(dayBands, (b) => b.startDoy, (b) => b.endDoy + 1);
+    // Days where the set of calendar colors changes — drawn as dashed verticals
+    // so a mid-year changeover reads as a transition, like the year axis.
+    const dayColorsAt = (d: number) =>
+        [...new Set(dayBands.filter((b) => b.startDoy <= d && d <= b.endDoy).map((b) => b.color))].sort().join('|');
+    const dayBoundaries = Array.from({ length: yearDays - 1 }, (_, i) => i + 2).filter(
+        (d) => dayColorsAt(d - 1) !== dayColorsAt(d),
+    );
+    // Each changeover day is a clickable tick (like the year axis's key dates):
+    // its label is the date the new calendar took effect, and clicking jumps the
+    // selection straight to that day.
+    const dayTickLabel = (d: number) => {
+        const { month, day } = fromDayOfYear(astroYear, d);
+        return `${MONTHS[month - 1].slice(0, 3)} ${day}`;
+    };
+    // Group ticks that are too close to label separately — render as a
+    // space-separated row at their midpoint so close changeovers (e.g. the
+    // 18-day 1871 Paris Commune) appear side-by-side on one line.
+    const DAY_TICK_CLUSTER_GAP_PCT = 8;
+    const dayTickClusters: { ticks: { d: number; pct: number }[]; midPct: number }[] = [];
+    for (const d of dayBoundaries) {
+        const pct = dayPct(d - 1);
+        const last = dayTickClusters[dayTickClusters.length - 1];
+        if (last && pct - last.ticks[0].pct < DAY_TICK_CLUSTER_GAP_PCT) {
+            last.ticks.push({ d, pct });
+            last.midPct = (last.ticks[0].pct + pct) / 2;
+        } else {
+            dayTickClusters.push({ ticks: [{ d, pct }], midPct: pct });
+        }
+    }
     const emitYear = (nextAstroYear: number) => {
         const { month, day } = fromDayOfYear(nextAstroYear, doy);
         onChange(fromHistoricalDate({ year: nextAstroYear, month, day }));
@@ -118,20 +164,64 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, min
                 {MONTHS[value.month - 1]} {value.day}, {yearLabel(astroYear)}
             </div>
 
-            {/* Day-of-year bar: grey track with a fill up to the current day */}
+            {/* Day-of-year bar: calendar-colored bands for the selected year
+                (or a grey progress fill when no region is pinned), with clickable
+                ticks at any mid-year changeover. */}
             <p style={barLabel}>Day of year</p>
             <div style={{ position: 'relative', height: 28 }}>
                 <div style={trackStyle}>
-                    <div
-                        style={{
-                            position: 'absolute',
-                            left: 0,
-                            width: `${(doy / yearDays) * 100}%`,
-                            top: 0,
-                            bottom: 0,
-                            background: '#adb5bd',
-                        }}
-                    />
+                    {dayBands.length > 0 ? (
+                        <>
+                            {/* Calendar-colored bands for the selected year: a
+                                changeover mid-year splits the track, so you can
+                                see *when in the year* the calendar changed. */}
+                            {dayLanes.map(({ item: band, lane }, i) => {
+                                const left = dayPct(band.startDoy - 1);
+                                const width = dayPct(band.endDoy) - left;
+                                return (
+                                    <div
+                                        key={i}
+                                        title={band.calendar}
+                                        style={{
+                                            position: 'absolute',
+                                            left: `${left}%`,
+                                            width: `${width}%`,
+                                            top: `${(lane / dayLaneCount) * 100}%`,
+                                            height: `${(1 / dayLaneCount) * 100}%`,
+                                            background: band.color,
+                                            opacity: 0.85,
+                                        }}
+                                    />
+                                );
+                            })}
+                            {dayBoundaries.map((d) => (
+                                <div
+                                    key={d}
+                                    aria-hidden
+                                    style={{
+                                        position: 'absolute',
+                                        left: `${dayPct(d - 1)}%`,
+                                        top: 0,
+                                        bottom: 0,
+                                        borderLeft: '1px dashed rgba(0, 0, 0, 0.4)',
+                                        pointerEvents: 'none',
+                                    }}
+                                />
+                            ))}
+                        </>
+                    ) : (
+                        // No region pinned: fall back to a plain progress fill.
+                        <div
+                            style={{
+                                position: 'absolute',
+                                left: 0,
+                                width: `${dayPct(doy)}%`,
+                                top: 0,
+                                bottom: 0,
+                                background: '#adb5bd',
+                            }}
+                        />
+                    )}
                 </div>
                 <input
                     className="timeline-slider"
@@ -145,18 +235,35 @@ const DatePicker: React.FC<DatePickerProps> = ({ value, onChange, yearBands, min
                     aria-valuetext={`${MONTHS[value.month - 1]} ${value.day}`}
                 />
             </div>
-            <div style={{ position: 'relative', height: 16, marginTop: 4, marginBottom: 16 }}>
-                {MONTH_INITIALS.map((initial, i) => {
-                    const left = ((dayOfYear(astroYear, i + 1, 1) - 1) / yearDays) * 100;
+            <div style={{ position: 'relative', height: 16, marginTop: 4, marginBottom: 16, fontSize: '0.7em', color: '#999' }}>
+                {dayTickClusters.length === 0 && MONTH_INITIALS.map((initial, i) => {
+                    const left = dayPct(dayOfYear(astroYear, i + 1, 1) - 1);
                     return (
-                        <span
-                            key={i}
-                            style={{ position: 'absolute', left: `${left}%`, fontSize: '0.7em', color: '#999' }}
-                        >
+                        <span key={i} style={{ position: 'absolute', top: 0, left: `${left}%` }}>
                             {initial}
                         </span>
                     );
                 })}
+                {/* Changeover ticks: close ticks are grouped into a cluster and
+                    rendered side-by-side at their midpoint so they never stack. */}
+                {dayTickClusters.map(({ ticks, midPct }) => (
+                    <span
+                        key={ticks[0].d}
+                        style={{ position: 'absolute', top: 0, left: `${midPct}%`, transform: 'translateX(-50%)', whiteSpace: 'nowrap', display: 'inline-flex', gap: '0.6em' }}
+                    >
+                        {ticks.map(({ d }) => (
+                            <button
+                                key={d}
+                                type="button"
+                                className="year-tick"
+                                onClick={() => emitDoy(d)}
+                                aria-label={`Jump to ${dayTickLabel(d)}`}
+                            >
+                                {dayTickLabel(d)}
+                            </button>
+                        ))}
+                    </span>
+                ))}
             </div>
 
             {/* Year bar with era-color bands behind the track */}
diff --git a/src/utils/timelineBands.test.ts b/src/utils/timelineBands.test.ts
new file mode 100644
index 0000000..b0608af
--- /dev/null
+++ b/src/utils/timelineBands.test.ts
@@ -0,0 +1,99 @@
+import { describe, it, expect } from 'vitest';
+import { effectiveErasForPoint, yearBandsForPoint, dayBandsForPoint } from './timelineBands';
+import { daysInYear } from './historicalDate';
+import type { CalendarRegion, Geometry } from '../types';
+
+const square: Geometry = {
+    type: 'Polygon',
+    coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]],
+};
+
+// A region that switches calendars mid-year, on 22 September 1792 (the French
+// Republican start) — a real intra-year changeover for the day-band tests.
+const switchover: CalendarRegion[] = [
+    {
+        region: { name: 'France', geo: square },
+        sourceFile: 'src/data/calendars/france.json',
+        eras: [
+            { name: 'Ancien régime', start: null, end: '1792-09-22', calendar: 'Gregorian' },
+            { name: 'Republic', start: '1792-09-22', end: '1806-01-01', calendar: 'French Republican calendar' },
+        ],
+    },
+];
+
+describe('effectiveErasForPoint', () => {
+    it('returns just the modern Gregorian default outside every region', () => {
+        // The default is appended unconditionally (mirroring the old year-band
+        // behavior); the day/year mappers clip it out of pre-modern years.
+        const eras = effectiveErasForPoint(50, 50, switchover);
+        expect(eras).toHaveLength(1);
+        expect(eras[0].name).toBe('Modern (global civil calendar)');
+        expect(eras[0].calendar).toBe('Gregorian');
+    });
+
+    it('appends the modern Gregorian default for a region without modern Gregorian', () => {
+        const eras = effectiveErasForPoint(5, 5, switchover);
+        expect(eras.map((e) => e.calendar)).toEqual([
+            'Gregorian', // ancien régime, ends 1792 (does not reach modern)
+            'French Republican calendar',
+            'Gregorian', // appended global default
+        ]);
+        expect(eras[eras.length - 1].name).toBe('Modern (global civil calendar)');
+    });
+
+    it('does not append the default when a region already has modern Gregorian', () => {
+        const modern: CalendarRegion[] = [
+            { region: { name: 'X', geo: square }, eras: [{ name: 'Now', start: '1900-01-01', end: null, calendar: 'Gregorian' }] },
+        ];
+        expect(effectiveErasForPoint(5, 5, modern)).toHaveLength(1);
+    });
+
+    it('suppresses the default when an era opts out and reaches modern times', () => {
+        const isolated: CalendarRegion[] = [
+            { region: { name: 'I', geo: square }, eras: [{ name: 'None', start: null, end: null, calendar: 'None', suppressModernDefault: true }] },
+        ];
+        const eras = effectiveErasForPoint(5, 5, isolated);
+        expect(eras).toHaveLength(1);
+        expect(eras[0].calendar).toBe('None');
+    });
+});
+
+describe('yearBandsForPoint', () => {
+    it('maps eras to year spans with ±Infinity for open ends', () => {
+        const bands = yearBandsForPoint(5, 5, switchover);
+        expect(bands[0]).toEqual({ start: -Infinity, end: 1792, calendar: 'Gregorian' });
+        expect(bands[1]).toEqual({ start: 1792, end: 1806, calendar: 'French Republican calendar' });
+        // appended modern default
+        expect(bands[2]).toEqual({ start: 1927, end: Infinity, calendar: 'Gregorian' });
+    });
+});
+
+describe('dayBandsForPoint', () => {
+    it('splits the changeover year at the exact day', () => {
+        // 1792 is a leap year; 22 Sept is day 266. The half-open boundary means
+        // the old calendar covers days 1–265 and the new one 266–366.
+        const bands = dayBandsForPoint(5, 5, switchover, 1792);
+        expect(daysInYear(1792)).toBe(366);
+        expect(bands).toEqual([
+            { startDoy: 1, endDoy: 265, calendar: 'Gregorian' },
+            { startDoy: 266, endDoy: 366, calendar: 'French Republican calendar' },
+        ]);
+    });
+
+    it('yields a single full-year band for a year with no changeover', () => {
+        const bands = dayBandsForPoint(5, 5, switchover, 1800);
+        expect(bands).toEqual([{ startDoy: 1, endDoy: 365, calendar: 'French Republican calendar' }]);
+    });
+
+    it('drops eras that do not reach into the selected year', () => {
+        // 1850: the region's eras have lapsed and the global default starts 1927,
+        // so no band covers this year here.
+        expect(dayBandsForPoint(5, 5, switchover, 1850)).toEqual([]);
+    });
+
+    it('covers the whole year with the modern default once it applies', () => {
+        // 2000 is a leap year (divisible by 400), so 366 days.
+        const bands = dayBandsForPoint(5, 5, switchover, 2000);
+        expect(bands).toEqual([{ startDoy: 1, endDoy: 366, calendar: 'Gregorian' }]);
+    });
+});
diff --git a/src/utils/timelineBands.ts b/src/utils/timelineBands.ts
new file mode 100644
index 0000000..d58ecf0
--- /dev/null
+++ b/src/utils/timelineBands.ts
@@ -0,0 +1,99 @@
+// Colored era bands for the date-picker timelines.
+//
+// Both the year axis and the day-of-year axis shade the picked region's
+// calendars over time. They share one source of truth here so the modern global
+// Gregorian default (see resolve.ts) is mirrored identically on both — the year
+// bar shows *which years* each calendar covered; the day bar zooms into one year
+// to show *which days*, so a mid-year changeover (e.g. France's 22 Sept 1792
+// Republican start) is visible as a color boundary within the year.
+
+import type { CalendarRegion, Era } from '../types';
+import { findRegions } from './geo';
+import { gregorianToJulianDay, parseGregorianDate } from './julianDay';
+import { daysInYear } from './historicalDate';
+import { DEFAULT_MODERN_ERA, MODERN_DEFAULT_START_YEAR } from './resolve';
+
+/** A stretch of the year axis using one calendar (years; ±Infinity open-ended). */
+export interface YearBandSpan {
+    start: number;
+    end: number;
+    calendar: string;
+}
+
+/** A stretch of the day-of-year axis using one calendar (1-based, inclusive). */
+export interface DayBandSpan {
+    startDoy: number;
+    endDoy: number;
+    calendar: string;
+}
+
+/**
+ * Every era in effect at a point, in render order: the matched regions' eras
+ * (in file/era order), plus the synthetic modern global Gregorian default when
+ * it applies — i.e. unless a matched region already supplies a Gregorian era
+ * reaching modern times, or an era opts the point out (suppressModernDefault).
+ * Mirrors resolve()'s default logic so the timelines and the answer cards agree.
+ */
+export function effectiveErasForPoint(
+    lat: number,
+    lng: number,
+    regions: CalendarRegion[],
+): Era[] {
+    const eras: Era[] = [];
+    let suppressesModernDefault = false;
+    let hasModernGregorian = false;
+    for (const cr of findRegions(lat, lng, regions)) {
+        for (const era of cr.eras) {
+            eras.push(era);
+            const endYear = era.end ? parseGregorianDate(era.end).year : Infinity;
+            // Only an era reaching modern times can block (or satisfy) the
+            // modern default — matching resolve() and the year-band logic.
+            if (era.suppressModernDefault && endYear >= MODERN_DEFAULT_START_YEAR) suppressesModernDefault = true;
+            if (era.calendar === 'Gregorian' && endYear >= MODERN_DEFAULT_START_YEAR) hasModernGregorian = true;
+        }
+    }
+    if (!hasModernGregorian && !suppressesModernDefault) eras.push(DEFAULT_MODERN_ERA);
+    return eras;
+}
+
+/** Year-axis bands for the picked point: each era's [startYear, endYear) span. */
+export function yearBandsForPoint(
+    lat: number,
+    lng: number,
+    regions: CalendarRegion[],
+): YearBandSpan[] {
+    return effectiveErasForPoint(lat, lng, regions).map((era) => ({
+        start: era.start ? parseGregorianDate(era.start).year : -Infinity,
+        end: era.end ? parseGregorianDate(era.end).year : Infinity,
+        calendar: era.calendar,
+    }));
+}
+
+/**
+ * Day-of-year bands for the picked point within a single (astronomical) year:
+ * each era clipped to that year's days, as a 1-based inclusive [startDoy,
+ * endDoy] range. Eras not touching the year are dropped, so most years yield a
+ * single full-width band (the calendar in use all year) and a changeover year
+ * splits into two — exactly where the user should "see" the change happen.
+ */
+export function dayBandsForPoint(
+    lat: number,
+    lng: number,
+    regions: CalendarRegion[],
+    astroYear: number,
+): DayBandSpan[] {
+    const yearStartJd = gregorianToJulianDay({ year: astroYear, month: 1, day: 1 });
+    const total = daysInYear(astroYear);
+    const bands: DayBandSpan[] = [];
+    for (const era of effectiveErasForPoint(lat, lng, regions)) {
+        // Half-open [start, end): day d (1-based) is covered when
+        // startJd <= yearStartJd + (d-1) < endJd.
+        const startJd = era.start ? gregorianToJulianDay(parseGregorianDate(era.start)) : -Infinity;
+        const endJd = era.end ? gregorianToJulianDay(parseGregorianDate(era.end)) : Infinity;
+        const startDoy = Math.max(1, startJd - yearStartJd + 1);
+        const endDoy = Math.min(total, endJd - yearStartJd); // end exclusive ⇒ last covered day
+        if (endDoy < startDoy) continue; // era doesn't reach into this year
+        bands.push({ startDoy, endDoy, calendar: era.calendar });
+    }
+    return bands;
+}