fuzzyworld

when-am-i / commit

feat: add Shuffle button for a random non-default-calendar scene

3adbd147b2d2676c19e90f5fd49f0f9e63424426 · Pavel Safronov · 2026-06-20 12:26:30 -0700 · parents 43f3aeff2e79

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

feat: add Shuffle button for a random non-default-calendar scene

Drops the time traveler at a random point inside a region and a random
date within one of that region's non-Gregorian eras, so each press
surfaces a historic reckoning (Islamic, Persian, Julian, lunar) rather
than the modern global Gregorian default.

- pickRandomScene() in utils/shuffle.ts: rejection-sample a point in the
  region polygon, pick a day in the era's half-open [start, end) span via
  JDN (open-ended eras clamped to 1000..2025), skip Gregorian eras.
- Wire a 🎲 Shuffle button into App; document it in the README.

Usage: 9.4K in / 26.1K out / 1.6M cached (session tally)
rtk:   95.7K saved, 84.6% over 201 cmds (global/lifetime)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 README.md                 |   2 +
 src/App.tsx               |  30 +++++++++++++-
 src/utils/shuffle.test.ts |  60 ++++++++++++++++++++++++++++
 src/utils/shuffle.ts      | 100 ++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 191 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index 84b362b..4149632 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,8 @@ Imagine you're a time traveler who ends up in a random place and time in history
 
 - Interactive world map for selecting a location
 - Date picker for choosing any year in history
+- **Shuffle** button that drops you at a random place and date on a non-default
+  (non-Gregorian) calendar — Islamic, Persian, Julian, or a lunar reckoning
 - Displays the local calendar year and system for the chosen place and time
 - Shows **all** valid answers at once — different people at the same place and time
   reckoned dates differently (e.g. colonial Julian alongside an Indigenous lunar calendar)
diff --git a/src/App.tsx b/src/App.tsx
index 325acad..0a6d260 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,6 +16,7 @@ import {
     type PickerDate,
 } from './utils/historicalDate';
 import { parseInitialState } from './utils/urlState';
+import { pickRandomScene } from './utils/shuffle';
 import {
     reportMailtoHref,
     setReportContext,
@@ -101,6 +102,14 @@ function App() {
     // Surface async errors caught by the global handlers as a dismissible banner.
     useEffect(() => subscribeReportErrors(setRuntimeError), []);
 
+    // Shuffle: jump to a random place-and-time on a non-default calendar.
+    const shuffle = () => {
+        const scene = pickRandomScene(regions);
+        if (!scene) return;
+        setPicked({ lat: scene.lat, lng: scene.lng });
+        setPickerDate(scene.date);
+    };
+
     return (
         <div style={{ maxWidth: 880, margin: '0 auto', padding: '24px 20px 48px' }}>
             <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -110,9 +119,28 @@ function App() {
             <p style={{ marginTop: 12 }}>
                 You&rsquo;re a time traveler who lands somewhere in history. You stop a local
                 and ask: <em>&ldquo;When am I?&rdquo;</em> Their answer depends on where
-                you are &mdash; and who you ask. Pick a date and a spot on the map to begin.
+                you are &mdash; and who you ask. Pick a date and a spot on the map to begin
+                &mdash; or hit <em>Shuffle</em> to be dropped somewhere on a calendar
+                that isn&rsquo;t today&rsquo;s.
             </p>
 
+            <button
+                type="button"
+                onClick={shuffle}
+                style={{
+                    margin: '4px 0 8px',
+                    padding: '8px 16px',
+                    fontSize: '0.95em',
+                    fontWeight: 600,
+                    cursor: 'pointer',
+                    border: '1px solid #bbb',
+                    borderRadius: 6,
+                    background: '#f6f6f6',
+                }}
+            >
+                🎲 Shuffle
+            </button>
+
             {runtimeError != null && (
                 <div
                     role="alert"
diff --git a/src/utils/shuffle.test.ts b/src/utils/shuffle.test.ts
new file mode 100644
index 0000000..6e3ccd6
--- /dev/null
+++ b/src/utils/shuffle.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest';
+import { pickRandomScene } from './shuffle';
+import { pointInPolygon } from './geo';
+import { findEras } from './eras';
+import { toHistoricalDate } from './historicalDate';
+import type { CalendarRegion, Geometry } from '../types';
+
+// A 10×10 square from (0,0) to (10,10) in [lng, lat].
+const square: Geometry = {
+    type: 'Polygon',
+    coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]],
+};
+
+const regions: CalendarRegion[] = [
+    {
+        region: { name: 'Square', geo: square },
+        eras: [
+            { name: 'Julian era', start: '1600-01-01', end: '1700-01-01', calendar: 'Julian' },
+            // A Gregorian era must never be chosen — it is the default shuffle avoids.
+            { name: 'Modern', start: '1700-01-01', end: null, calendar: 'Gregorian' },
+        ],
+    },
+];
+
+// A deterministic rng cycling through fixed values.
+function seq(values: number[]): () => number {
+    let i = 0;
+    return () => values[i++ % values.length];
+}
+
+describe('pickRandomScene', () => {
+    it('returns null when the dataset has only Gregorian (default) eras', () => {
+        const onlyGregorian: CalendarRegion[] = [
+            { region: { name: 'G', geo: square }, eras: [{ name: 'M', start: null, end: null, calendar: 'Gregorian' }] },
+        ];
+        expect(pickRandomScene(onlyGregorian, seq([0.5]))).toBeNull();
+    });
+
+    it('lands on a non-Gregorian era, inside the region, within the era span', () => {
+        const scene = pickRandomScene(regions, seq([0.3]));
+        expect(scene).not.toBeNull();
+        if (!scene) return;
+
+        // The point is inside the region.
+        expect(pointInPolygon(scene.lat, scene.lng, square)).toBe(true);
+
+        // The date resolves to a non-Gregorian era covering it.
+        const eras = findEras(toHistoricalDate(scene.date), regions[0].eras);
+        expect(eras.map((e) => e.calendar)).toContain('Julian');
+        expect(eras.map((e) => e.calendar)).not.toContain('Gregorian');
+    });
+
+    it('produces dates strictly before a bounded era end (half-open)', () => {
+        // rng near 1 should still keep the day before the end boundary.
+        const scene = pickRandomScene(regions, seq([0, 0.5, 0.5, 0.999999]));
+        expect(scene).not.toBeNull();
+        if (!scene) return;
+        expect(scene.date.year).toBeLessThan(1700);
+    });
+});
diff --git a/src/utils/shuffle.ts b/src/utils/shuffle.ts
new file mode 100644
index 0000000..fe6c1d4
--- /dev/null
+++ b/src/utils/shuffle.ts
@@ -0,0 +1,100 @@
+// Shuffle: drop the time traveler somewhere — and somewhen — the dataset gives
+// a genuinely different answer.
+//
+// The app's *default* answer is the modern global Gregorian calendar (see
+// resolve.ts). "Shuffle" is the opposite: it picks a random point inside a
+// region and a random date within one of that region's non-Gregorian eras, so
+// each press surfaces one of the historic reckonings (Islamic, Persian, Julian,
+// a lunar calendar) rather than the everyday Gregorian default.
+
+import type { CalendarRegion, Era, Geometry, LinearRing } from '../types';
+import { pointInPolygon } from './geo';
+import { gregorianToJulianDay, julianDayToGregorian, parseGregorianDate } from './julianDay';
+import { fromHistoricalDate, type PickerDate } from './historicalDate';
+
+/** A random place-and-time the shuffle button drops you at. */
+export interface ShuffleScene {
+    lat: number;
+    lng: number;
+    date: PickerDate;
+}
+
+// Eras open at one end (null start / null end) still need a finite window to
+// sample a date from, so we clamp the open side to these proleptic-Gregorian
+// bounds — a reasonable historic span without spilling into the modern global
+// Gregorian default that shuffle is meant to avoid.
+const OPEN_PAST = parseGregorianDate('1000-01-01');
+const OPEN_FUTURE = parseGregorianDate('2025-12-31');
+
+/** Every ring across a Polygon or MultiPolygon. */
+function allRings(geo: Geometry): LinearRing[] {
+    return geo.type === 'Polygon' ? geo.coordinates : geo.coordinates.flat();
+}
+
+/** Bounding box (in lat/lng) of a geometry's coordinates. */
+function boundingBox(geo: Geometry) {
+    let minLat = Infinity;
+    let maxLat = -Infinity;
+    let minLng = Infinity;
+    let maxLng = -Infinity;
+    for (const ring of allRings(geo)) {
+        for (const [lng, lat] of ring) {
+            if (lat < minLat) minLat = lat;
+            if (lat > maxLat) maxLat = lat;
+            if (lng < minLng) minLng = lng;
+            if (lng > maxLng) maxLng = lng;
+        }
+    }
+    return { minLat, maxLat, minLng, maxLng };
+}
+
+/**
+ * A random point inside the geometry, by rejection sampling its bounding box.
+ * Falls back to the bbox centre if no hit is found (degenerate geometry).
+ */
+function randomPointInGeometry(geo: Geometry, rng: () => number): { lat: number; lng: number } {
+    const { minLat, maxLat, minLng, maxLng } = boundingBox(geo);
+    for (let i = 0; i < 1000; i++) {
+        const lat = minLat + rng() * (maxLat - minLat);
+        const lng = minLng + rng() * (maxLng - minLng);
+        if (pointInPolygon(lat, lng, geo)) return { lat, lng };
+    }
+    return { lat: (minLat + maxLat) / 2, lng: (minLng + maxLng) / 2 };
+}
+
+/** A random date inside an era's half-open [start, end) span. */
+function randomDateInEra(era: Era, rng: () => number): PickerDate {
+    const startJDN = gregorianToJulianDay(era.start ? parseGregorianDate(era.start) : OPEN_PAST);
+    const endJDN = gregorianToJulianDay(era.end ? parseGregorianDate(era.end) : OPEN_FUTURE);
+    // Half-open interval: keep the day strictly before the end boundary.
+    const span = Math.max(1, endJDN - startJDN);
+    const jdn = startJDN + Math.floor(rng() * span);
+    return fromHistoricalDate(julianDayToGregorian(jdn));
+}
+
+/**
+ * Pick a random scene that lands on a non-default (non-Gregorian) calendar:
+ * a random point inside some region and a random date within one of that
+ * region's non-Gregorian eras. Returns null if the dataset has no such era.
+ *
+ * `rng` is injectable for deterministic tests; it defaults to Math.random.
+ */
+export function pickRandomScene(
+    regions: CalendarRegion[],
+    rng: () => number = Math.random,
+): ShuffleScene | null {
+    const candidates: { geo: Geometry; era: Era }[] = [];
+    for (const cr of regions) {
+        for (const era of cr.eras) {
+            if (era.calendar !== 'Gregorian') {
+                candidates.push({ geo: cr.region.geo, era });
+            }
+        }
+    }
+    if (candidates.length === 0) return null;
+
+    const { geo, era } = candidates[Math.floor(rng() * candidates.length)];
+    const { lat, lng } = randomPointInGeometry(geo, rng);
+    const date = randomDateInEra(era, rng);
+    return { lat, lng, date };
+}