when-am-i / commit
feat: shareable URL state serializer
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()}`; +}