fuzzyworld

when-am-i / commit

feat: shareable URL state serializer

631012abe1e4bf1270f82af724c3d2b4b794cc6c · Pavel Safronov · 2026-06-20 19:15:40 -0700 · parents 998b11ce0f02

Claude Code usage: this commit ≈0 out / ≈0 cached · cost unknown (no per-model trailer) · session 3 tally 37.2K in / 500.1K out / 63.5M cached · usage by commit and session

feat: shareable URL state serializer

Add toSearch(picked, date, zoom) — the inverse of parseInitialState — so the
view can be written back to the URL as a shareable link to the same place, date,
and zoom, reusing the existing ?lat&lng&zoom&year&month&day&era params.

Usage: 37.2K in / 500.1K out / 63.5M cached (session tally)
rtk:   2.2M saved, 98.7% over 277 cmds (global/lifetime)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 src/utils/urlState.test.ts | 24 +++++++++++++++++++++++-
 src/utils/urlState.ts      | 28 +++++++++++++++++++++++++++-
 2 files changed, 50 insertions(+), 2 deletions(-)

diff --git a/src/utils/urlState.test.ts b/src/utils/urlState.test.ts
index a64876f..80df9fa 100644
--- a/src/utils/urlState.test.ts
+++ b/src/utils/urlState.test.ts
@@ -1,5 +1,5 @@
 import { describe, it, expect } from 'vitest';
-import { parseInitialState } from './urlState';
+import { parseInitialState, toSearch } from './urlState';
 
 describe('parseInitialState', () => {
     it('parses a full location + date', () => {
@@ -30,3 +30,25 @@ describe('parseInitialState', () => {
         expect(parseInitialState('')).toEqual({});
     });
 });
+
+describe('toSearch', () => {
+    it('serializes pin, zoom, and date into the same params parseInitialState reads', () => {
+        const search = toSearch({ lat: 42.36, lng: -71.06 }, { year: 1700, month: 4, day: 1, era: 'CE' }, 6);
+        const parsed = parseInitialState(search);
+        expect(parsed.picked).toEqual({ lat: 42.36, lng: -71.06 });
+        expect(parsed.zoom).toBe(6);
+        expect(parsed.date).toEqual({ year: 1700, month: 4, day: 1 });
+    });
+
+    it('round-trips a BCE date', () => {
+        const search = toSearch(null, { year: 44, month: 3, day: 15, era: 'BCE' }, 2);
+        expect(search).toContain('era=BCE');
+        expect(parseInitialState(search).date).toEqual({ year: -43, month: 3, day: 15 });
+    });
+
+    it('omits the pin when nothing is picked but still records the date', () => {
+        const search = toSearch(null, { year: 2000, month: 1, day: 1, era: 'CE' }, 3);
+        expect(search).not.toContain('lat=');
+        expect(parseInitialState(search).date).toEqual({ year: 2000, month: 1, day: 1 });
+    });
+});
diff --git a/src/utils/urlState.ts b/src/utils/urlState.ts
index b524b0c..e5e12e7 100644
--- a/src/utils/urlState.ts
+++ b/src/utils/urlState.ts
@@ -6,7 +6,7 @@
 // All params are optional; a missing or malformed group is simply ignored.
 
 import type { HistoricalDate } from '../types';
-import { toHistoricalDate } from './historicalDate';
+import { toHistoricalDate, type PickerDate } from './historicalDate';
 
 export interface InitialState {
     picked?: { lat: number; lng: number };
@@ -41,3 +41,29 @@ export function parseInitialState(search: string): InitialState {
 
     return state;
 }
+
+/**
+ * Serialize the current view to a query string (the inverse of
+ * `parseInitialState`), so the URL is a shareable link to the same place, date,
+ * and zoom. Uses the same `?lat&lng&zoom&year&month&day&era` params the
+ * screenshot tooling reads. The pin is the "position"; zoom is included so the
+ * framing matches. Returns a leading "?" (or "" when nothing is pinned).
+ */
+export function toSearch(
+    picked: { lat: number; lng: number } | null,
+    date: PickerDate,
+    zoom: number | null,
+): string {
+    const params = new URLSearchParams();
+    if (picked) {
+        // Trim to ~1 m so the link stays short without losing the spot.
+        params.set('lat', picked.lat.toFixed(5));
+        params.set('lng', picked.lng.toFixed(5));
+    }
+    if (zoom !== null) params.set('zoom', String(Math.round(zoom)));
+    params.set('year', String(date.year));
+    params.set('month', String(date.month));
+    params.set('day', String(date.day));
+    params.set('era', date.era);
+    return `?${params.toString()}`;
+}