diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 95d7fdcb..0d6ac446 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -353,7 +353,10 @@ assert( ) await page.getByLabel('Add to favourites').click() await page.waitForTimeout(300) -const stored = await page.evaluate(() => localStorage.getItem('playa-map.favorites.v1')) +const stored = await page.evaluate( + (year) => localStorage.getItem(`playa-map.favorites.v1.${year}`), + DATA_YEAR, +) assert(Boolean(stored) && stored !== '[]', `favourite persisted (${stored})`) // Walk and bike estimates should both be present in the drawer. diff --git a/src/App.tsx b/src/App.tsx index b9c1d1da..7d8ef960 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -88,7 +88,7 @@ import { type DirectionsReadResult, type DirectionsMode, } from './data/directions' -import { resolveDirectionsRoute } from './data/directionsRuntime' +import { resolveDirectionsEndpoint, resolveDirectionsRoute } from './data/directionsRuntime' import { shareRouteCard } from './ui/routeCard' type Filter = PoiKind | 'toilets' | 'services' | 'favorites' @@ -914,6 +914,15 @@ export default function App() { // a standalone answer to "where am I". const liveAddressLabel = usableFix && data ? reverseGeocode(usableFix, data.layout).label : undefined + const directionsDestinationResolved = useMemo(() => { + if (!data || !directionsTo) return false + return Boolean(resolveDirectionsEndpoint(directionsTo, { + layout: data.layout, + pois: data.pois, + livePosition: usableFix, + })) + }, [data, directionsTo, usableFix]) + const directionsPreview = useMemo(() => { if (!data || !directionsTo) return undefined const resolved = resolveDirectionsRoute(directionsFrom, directionsTo, { @@ -1187,14 +1196,17 @@ export default function App() { ]) const shareDirections = useCallback(async () => { - if (!directionsTo) return + if (!directionsTo || !directionsDestinationResolved) { + if (directionsTo) setProbe('This destination is no longer available to share') + return + } const result = await shareLink( directionsUrl({ version: 1, from: directionsFrom, to: directionsTo, mode: directionsMode }), 'Dust Compass directions', ) if (result === 'copied') setProbe('Route link copied') else if (result === 'unavailable') setProbe('Could not copy the route link') - }, [directionsFrom, directionsMode, directionsTo]) + }, [directionsDestinationResolved, directionsFrom, directionsMode, directionsTo]) const shareDirectionsImage = useCallback(async () => { if (!directionsPreview || !data) return @@ -2328,6 +2340,7 @@ export default function App() { mode={directionsMode} hasUsableLiveFix={Boolean(usableFix)} findingLocation={location.status === 'locating'} + destinationResolved={directionsDestinationResolved} preview={directionsPreview ? { fromLabel: directionsPreview.resolved.from.label, toLabel: directionsPreview.resolved.to.label, diff --git a/src/data/__tests__/navigationLifecycleRegressions.test.tsx b/src/data/__tests__/navigationLifecycleRegressions.test.tsx new file mode 100644 index 00000000..355707e2 --- /dev/null +++ b/src/data/__tests__/navigationLifecycleRegressions.test.tsx @@ -0,0 +1,95 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useCompassHeading } from '../useCompassHeading' +import { useWakeLock } from '../useWakeLock' + +function dispatchOrientation(alpha: number) { + window.dispatchEvent(Object.assign(new Event('deviceorientation'), { alpha, absolute: true })) +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +describe('compass sample lifecycle (#165)', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('keeps permission but hides the old sample until a fresh event arrives after restart', async () => { + class NoGateDeviceOrientationEvent {} + vi.stubGlobal('DeviceOrientationEvent', NoGateDeviceOrientationEvent) + const { result, rerender } = renderHook(({ active }) => useCompassHeading(active), { + initialProps: { active: true }, + }) + + await act(async () => result.current.requestPermission()) + act(() => dispatchOrientation(10)) + expect(result.current.support).toBe('active') + expect(result.current.heading).toBe(350) + + rerender({ active: false }) + await act(async () => {}) + expect(result.current.support).toBe('active') + expect(result.current.heading).toBeUndefined() + expect(result.current.accuracy).toBeUndefined() + + // Device movement while inactive is not observed. + act(() => dispatchOrientation(180)) + rerender({ active: true }) + await act(async () => {}) + expect(result.current.heading).toBeUndefined() + + act(() => dispatchOrientation(90)) + expect(result.current.heading).toBe(270) + }) +}) + +describe('wake-lock acquisition ownership (#166)', () => { + afterEach(() => { + vi.restoreAllMocks() + // @ts-expect-error test-only API cleanup + delete navigator.wakeLock + }) + + it('does not start a second request while the first acquisition is pending', async () => { + const pending = deferred() + const release = vi.fn(() => Promise.resolve()) + const sentinel = { release, addEventListener: vi.fn() } as unknown as WakeLockSentinel + const request = vi.fn(() => pending.promise) + Object.defineProperty(navigator, 'wakeLock', { value: { request }, configurable: true }) + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + + const { rerender } = renderHook(({ active }) => useWakeLock(active), { + initialProps: { active: true }, + }) + expect(request).toHaveBeenCalledTimes(1) + + act(() => document.dispatchEvent(new Event('visibilitychange'))) + act(() => document.dispatchEvent(new Event('visibilitychange'))) + expect(request).toHaveBeenCalledTimes(1) + + await act(async () => pending.resolve(sentinel)) + rerender({ active: false }) + await act(async () => {}) + expect(release).toHaveBeenCalledTimes(1) + }) + + it('releases a sentinel that resolves after navigation already stopped', async () => { + const pending = deferred() + const release = vi.fn(() => Promise.resolve()) + const sentinel = { release, addEventListener: vi.fn() } as unknown as WakeLockSentinel + const request = vi.fn(() => pending.promise) + Object.defineProperty(navigator, 'wakeLock', { value: { request }, configurable: true }) + + const { rerender } = renderHook(({ active }) => useWakeLock(active), { + initialProps: { active: true }, + }) + rerender({ active: false }) + await act(async () => pending.resolve(sentinel)) + expect(release).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/data/__tests__/persistenceInvariants.test.tsx b/src/data/__tests__/persistenceInvariants.test.tsx new file mode 100644 index 00000000..ac1cd1cf --- /dev/null +++ b/src/data/__tests__/persistenceInvariants.test.tsx @@ -0,0 +1,121 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + MAX_SAVED_PLACE_NAME_LENGTH, + normalizeSavedPlaceName, + parsePlaces, + useSavedPlaces, + type SavedPlace, +} from '../useSavedPlaces' +import { + parseSavedEvents, + savedEventMatches, + useSavedEvents, +} from '../useSavedEvents' +import { parseFavorites } from '../useFavorites' +import type { EventItem } from '../types' + +const makeEvent = (title: string, eventId = 42): EventItem => ({ + uid: 'evt-boundary', + title, + event_id: eventId, + year: 2026, + occurrence_set: [{ start_time: '2026-08-30T12:00:00-07:00', end_time: '2026-08-30T13:00:00-07:00' }], +}) + +function findStorageKey(prefix: string): string { + const key = Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index)) + .find((candidate) => candidate?.startsWith(prefix)) + if (!key) throw new Error(`missing storage key ${prefix}`) + return key +} + +describe('saved-place write/read invariant (#161)', () => { + beforeEach(() => localStorage.clear()) + + it.each([199, 200, 201])('round-trips a %i-character requested name safely', (length) => { + const requested = 'x'.repeat(length) + const { result } = renderHook(() => useSavedPlaces()) + let created: SavedPlace | undefined + act(() => { + created = result.current.save(requested, [-119.2, 40.78], '6:00 & A').place + }) + if (!created) throw new Error('save did not create a place') + expect(created.name.length).toBe(Math.min(length, MAX_SAVED_PLACE_NAME_LENGTH)) + expect(created.name).toBe(normalizeSavedPlaceName(requested)) + expect(parsePlaces(localStorage.getItem(findStorageKey('playa-map.places.v1.')))).toContainEqual(created) + }) +}) + +describe('saved-event write/read and identity invariants (#162/#164)', () => { + beforeEach(() => localStorage.clear()) + + it.each([299, 300, 301, 1000])('keeps a valid %i-character event title durable', (length) => { + const event = makeEvent('t'.repeat(length)) + const { result } = renderHook(() => useSavedEvents()) + act(() => { + expect(result.current.save(event)).toBe(true) + }) + const saved = result.current.savedEvents[0] + if (!saved) throw new Error('event did not save') + expect(saved.title).toBe(event.title) + expect(saved.eventId).toBe(event.event_id) + expect(parseSavedEvents(localStorage.getItem(findStorageKey('playa-map.saved-events.v1.')))).toContainEqual(saved) + }) + + it('accepts a normal update to the same event identity and rejects uid reuse', () => { + const saved = { uid: 'evt-boundary', title: 'Original title', savedAt: 1, eventId: 42 } + expect(savedEventMatches(saved, makeEvent('Updated title', 42))).toBe(true) + expect(savedEventMatches(saved, makeEvent('Unrelated replacement', 99))).toBe(false) + }) + + it('keeps legacy bookmarks conservative when only uid and title are available', () => { + const legacy = { uid: 'evt-boundary', title: 'Original title', savedAt: 1 } + expect(savedEventMatches(legacy, makeEvent('Original title', 99))).toBe(true) + expect(savedEventMatches(legacy, makeEvent('Different title', 99))).toBe(false) + }) +}) + +describe('favorites annual isolation and storage validation (#163)', () => { + beforeEach(() => { + localStorage.clear() + vi.resetModules() + }) + + it('accepts only non-empty string uids', () => { + expect(parseFavorites(JSON.stringify(['camp-1', '', ' ', 42, null, 'art-2']))).toEqual( + new Set(['camp-1', 'art-2']), + ) + }) + + it('rejects malformed and non-array storage', () => { + expect(parseFavorites('not json')).toEqual(new Set()) + expect(parseFavorites('{"uid":"camp-1"}')).toEqual(new Set()) + }) + + it('does not apply a year-A favorite to year B even when the uid is reused, and restores it on return to year A', async () => { + vi.doMock('../../config', () => ({ DATA_YEAR: '2025' })) + const { useFavorites: useFavorites2025 } = await import('../useFavorites') + const first = renderHook(() => useFavorites2025()) + act(() => first.result.current.toggle('same-uid')) + expect(first.result.current.isFavorite('same-uid')).toBe(true) + first.unmount() + + vi.resetModules() + vi.doMock('../../config', () => ({ DATA_YEAR: '2026' })) + const { useFavorites: useFavorites2026 } = await import('../useFavorites') + const second = renderHook(() => useFavorites2026()) + expect(second.result.current.isFavorite('same-uid')).toBe(false) + second.unmount() + + vi.resetModules() + vi.doMock('../../config', () => ({ DATA_YEAR: '2025' })) + const { useFavorites: useFavorites2025Again } = await import('../useFavorites') + const returned = renderHook(() => useFavorites2025Again()) + expect(returned.result.current.isFavorite('same-uid')).toBe(true) + returned.unmount() + }) +}) diff --git a/src/data/__tests__/useCompassHeading.test.ts b/src/data/__tests__/useCompassHeading.test.ts index aac6f8c6..046b6010 100644 --- a/src/data/__tests__/useCompassHeading.test.ts +++ b/src/data/__tests__/useCompassHeading.test.ts @@ -5,11 +5,6 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useCompassHeading } from '../useCompassHeading' -/** - * jsdom implements no Device Orientation API at all, so every scenario here - * stands up its own `DeviceOrientationEvent` global (or deliberately leaves - * it absent) rather than relying on anything jsdom provides. - */ function dispatchOrientation( name: 'deviceorientation' | 'deviceorientationabsolute', props: { alpha?: number; absolute?: boolean; webkitCompassHeading?: number; webkitCompassAccuracy?: number }, @@ -28,8 +23,6 @@ describe('useCompassHeading (#63)', () => { const { result } = renderHook(() => useCompassHeading(true)) expect(result.current.support).toBe('unsupported') expect(result.current.heading).toBeUndefined() - - // Calling it anyway (e.g. a stray click) must be a safe no-op, never a throw. await act(async () => { await result.current.requestPermission() }) @@ -39,33 +32,27 @@ describe('useCompassHeading (#63)', () => { it('starts at idle (no permission gate) on a platform like Android, and does not listen until requestPermission is called', () => { class NoGateDeviceOrientationEvent {} vi.stubGlobal('DeviceOrientationEvent', NoGateDeviceOrientationEvent) - const { result } = renderHook(() => useCompassHeading(true)) expect(result.current.support).toBe('idle') - act(() => { dispatchOrientation('deviceorientation', { alpha: 90, absolute: true }) }) - // No listener attached yet — the tap-gated affordance is uniform across - // platforms even where the browser itself requires no prompt. expect(result.current.heading).toBeUndefined() }) it('activates and starts receiving heading once requestPermission is called on a no-gate platform', async () => { class NoGateDeviceOrientationEvent {} vi.stubGlobal('DeviceOrientationEvent', NoGateDeviceOrientationEvent) - const { result } = renderHook(() => useCompassHeading(true)) await act(async () => { await result.current.requestPermission() }) expect(result.current.support).toBe('active') - act(() => { dispatchOrientation('deviceorientation', { alpha: 90, absolute: true }) }) - expect(result.current.heading).toBeCloseTo(270) // (360 - 90) % 360 - expect(result.current.accuracy).toBeUndefined() // no standard accuracy field + expect(result.current.heading).toBeCloseTo(270) + expect(result.current.accuracy).toBeUndefined() }) it('starts at needs-permission on an iOS-Safari-like platform', () => { @@ -73,7 +60,6 @@ describe('useCompassHeading (#63)', () => { static requestPermission = vi.fn().mockResolvedValue('granted') } vi.stubGlobal('DeviceOrientationEvent', GatedDeviceOrientationEvent) - const { result } = renderHook(() => useCompassHeading(true)) expect(result.current.support).toBe('needs-permission') }) @@ -83,7 +69,6 @@ describe('useCompassHeading (#63)', () => { static requestPermission = vi.fn().mockResolvedValue('denied') } vi.stubGlobal('DeviceOrientationEvent', GatedDeviceOrientationEvent) - const { result } = renderHook(() => useCompassHeading(true)) await act(async () => { await result.current.requestPermission() @@ -98,10 +83,8 @@ describe('useCompassHeading (#63)', () => { static requestPermission = requestPermission } vi.stubGlobal('DeviceOrientationEvent', GatedDeviceOrientationEvent) - const { result } = renderHook(() => useCompassHeading(true)) await act(async () => result.current.requestPermission()) - expect(requestPermission).toHaveBeenCalledWith(true) }) @@ -110,9 +93,7 @@ describe('useCompassHeading (#63)', () => { vi.stubGlobal('DeviceOrientationEvent', NoGateDeviceOrientationEvent) const { result } = renderHook(() => useCompassHeading(true)) await act(async () => result.current.requestPermission()) - act(() => dispatchOrientation('deviceorientation', { alpha: 90, absolute: false })) - expect(result.current.heading).toBeUndefined() }) @@ -121,7 +102,6 @@ describe('useCompassHeading (#63)', () => { static requestPermission = vi.fn().mockRejectedValue(new Error('not a user gesture')) } vi.stubGlobal('DeviceOrientationEvent', GatedDeviceOrientationEvent) - const { result } = renderHook(() => useCompassHeading(true)) await act(async () => { await result.current.requestPermission() @@ -134,12 +114,10 @@ describe('useCompassHeading (#63)', () => { static requestPermission = vi.fn().mockResolvedValue('granted') } vi.stubGlobal('DeviceOrientationEvent', GatedDeviceOrientationEvent) - const { result } = renderHook(() => useCompassHeading(true)) await act(async () => { await result.current.requestPermission() }) - act(() => { dispatchOrientation('deviceorientation', { alpha: 12, webkitCompassHeading: 271.4, webkitCompassAccuracy: 5 }) }) @@ -152,12 +130,10 @@ describe('useCompassHeading (#63)', () => { static requestPermission = vi.fn().mockResolvedValue('granted') } vi.stubGlobal('DeviceOrientationEvent', GatedDeviceOrientationEvent) - const { result } = renderHook(() => useCompassHeading(true)) await act(async () => { await result.current.requestPermission() }) - act(() => { dispatchOrientation('deviceorientation', { webkitCompassHeading: 90, webkitCompassAccuracy: -1 }) }) @@ -168,26 +144,21 @@ describe('useCompassHeading (#63)', () => { it('prefers deviceorientationabsolute when the platform exposes it', async () => { class NoGateDeviceOrientationEvent {} vi.stubGlobal('DeviceOrientationEvent', NoGateDeviceOrientationEvent) - // Feature-detected via `'ondeviceorientationabsolute' in window`. Object.defineProperty(window, 'ondeviceorientationabsolute', { value: null, configurable: true }) - const { result } = renderHook(() => useCompassHeading(true)) await act(async () => { await result.current.requestPermission() }) - act(() => { dispatchOrientation('deviceorientationabsolute', { alpha: 45, absolute: true }) }) expect(result.current.heading).toBeCloseTo(315) - Reflect.deleteProperty(window, 'ondeviceorientationabsolute') }) - it('stops listening once `active` goes false (navigation ended) without throwing', async () => { + it('stops listening and invalidates the previous navigation sample when `active` goes false (#165)', async () => { class NoGateDeviceOrientationEvent {} vi.stubGlobal('DeviceOrientationEvent', NoGateDeviceOrientationEvent) - const { result, rerender } = renderHook(({ active }) => useCompassHeading(active), { initialProps: { active: true }, }) @@ -200,11 +171,22 @@ describe('useCompassHeading (#63)', () => { expect(result.current.heading).toBeCloseTo(0) rerender({ active: false }) + await act(async () => {}) + expect(result.current.support).toBe('active') + expect(result.current.heading).toBeUndefined() + expect(result.current.accuracy).toBeUndefined() + act(() => { dispatchOrientation('deviceorientation', { alpha: 180, absolute: true }) }) - // The listener was removed, so the stale reading from before is what's - // left — not a crash, and not a silently-still-live subscription. - expect(result.current.heading).toBeCloseTo(0) + expect(result.current.heading).toBeUndefined() + + rerender({ active: true }) + await act(async () => {}) + expect(result.current.heading).toBeUndefined() + act(() => { + dispatchOrientation('deviceorientation', { alpha: 90, absolute: true }) + }) + expect(result.current.heading).toBeCloseTo(270) }) }) diff --git a/src/data/__tests__/usePlayaDataRace.test.tsx b/src/data/__tests__/usePlayaDataRace.test.tsx new file mode 100644 index 00000000..048595b3 --- /dev/null +++ b/src/data/__tests__/usePlayaDataRace.test.tsx @@ -0,0 +1,78 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CityLayout } from '../../brc/layout' +import { usePlayaData } from '../usePlayaData' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +function response(body: unknown): Response { + return { ok: true, status: 200, statusText: 'OK', json: () => Promise.resolve(body) } as Response +} + +function layout(centerLng: number): CityLayout { + return { + center: { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [centerLng, 40.78] } }, + bearing: 45, + fence_distance: 10560, + road_width: 40, + cStreets: [], + tStreets: [], + plazas: [], + portals: [], + } +} + +describe('usePlayaData reload supersession (#167)', () => { + afterEach(() => { + vi.unstubAllGlobals() + // @ts-expect-error test-only service worker cleanup + delete navigator.serviceWorker + }) + + it('keeps the later DATA_REFRESHED load when an older load resolves last', async () => { + const layoutA = deferred() + const layoutB = deferred() + let call = 0 + const fetchMock = vi.fn((url: string) => { + const batch = Math.floor(call / 8) + call += 1 + if (url.endsWith('layout.json')) return batch === 0 ? layoutA.promise : layoutB.promise + if (url.endsWith('art.json') || url.endsWith('camp.json') || url.endsWith('event.json')) { + return Promise.resolve(response([])) + } + if (url.endsWith('dates_info.json')) return Promise.resolve(response({})) + if (url.endsWith('.geojson')) { + return Promise.resolve(response({ type: 'FeatureCollection', features: [] })) + } + throw new Error(`unexpected fetch ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) + + const serviceWorker = new EventTarget() + Object.defineProperty(navigator, 'serviceWorker', { value: serviceWorker, configurable: true }) + + const { result } = renderHook(() => usePlayaData()) + expect(fetchMock).toHaveBeenCalledTimes(8) + + act(() => { + serviceWorker.dispatchEvent(new MessageEvent('message', { data: { type: 'DATA_REFRESHED' } })) + }) + expect(fetchMock).toHaveBeenCalledTimes(16) + + await act(async () => layoutB.resolve(response(layout(-119.25)))) + await waitFor(() => expect(result.current.data?.layout.center.geometry.coordinates[0]).toBe(-119.25)) + + await act(async () => layoutA.resolve(response(layout(-119.15)))) + await act(async () => {}) + + // Older A finishes last but cannot regress visible state after B won. + expect(result.current.data?.layout.center.geometry.coordinates[0]).toBe(-119.25) + }) +}) diff --git a/src/data/useCompassHeading.ts b/src/data/useCompassHeading.ts index 076cd465..8d2943cd 100644 --- a/src/data/useCompassHeading.ts +++ b/src/data/useCompassHeading.ts @@ -31,25 +31,6 @@ interface IOSOrientationEvent extends DeviceOrientationEvent { webkitCompassAccuracy?: number } -/** - * Reads the device's physical compass heading — not the MapLibre camera - * bearing, which is a separate, unrelated rotation the map already handles - * via its own "12:00 up"/"North up" controls (#63). This is the sensor a - * needle points with. - * - * Mirrors `useWakeLock`'s shape: feature-detection lives in `useState`'s - * lazy initializer, the listener is added/removed by an effect gated on - * `active`, and it never throws — an unsupported browser or a denied prompt - * degrades to "no needle", not a broken screen. - * - * iOS Safari gates orientation events behind a permission prompt that must - * be triggered by a tap (`DeviceOrientationEvent.requestPermission`). - * Android and everything else has no such gate — events just start firing - * once a listener exists — so `support` starts at `'idle'` there instead of - * `'needs-permission'`, but the listener still only attaches once - * `requestPermission()` has been called at least once, so the same - * "tap the compass on" affordance works uniformly on every platform. - */ export function useCompassHeading(active: boolean): CompassHeading { const [support, setSupport] = useState(() => { if (typeof window === 'undefined' || typeof DeviceOrientationEvent === 'undefined') { @@ -66,31 +47,34 @@ export function useCompassHeading(active: boolean): CompassHeading { const ctor = DeviceOrientationEvent as unknown as RequestPermissionCtor if (typeof ctor.requestPermission === 'function') { try { - // The current Device Orientation permission API accepts `true` to - // request absolute (magnetometer-backed) orientation. Older Safari - // implementations ignore the extra argument and still return their - // iOS-specific webkitCompassHeading below. const result = await ctor.requestPermission(true) setSupport(result === 'granted' ? 'active' : 'denied') } catch { - // Denied, or asked outside a user gesture — either way, degrade to - // the existing text/route navigation rather than a broken control. setSupport('denied') } return } - // No permission gate on this platform — "turning the compass on" just - // means the listener effect below is now allowed to attach. setSupport('active') }, [support]) + // A compass sample is valid only for the navigation/listening session that + // produced it. Keep permission state, but invalidate sensor data as soon as + // navigation stops so a later route cannot paint the old needle before a + // fresh orientation event arrives (#165). Queueing avoids a synchronous + // setState cascade inside the effect body while still clearing before paint. + useEffect(() => { + if (active) return + queueMicrotask(() => { + setHeading(undefined) + setAccuracy(undefined) + }) + }, [active]) + useEffect(() => { if (support !== 'active' || !active) return if (typeof window === 'undefined' || typeof DeviceOrientationEvent === 'undefined') return let cancelled = false - // Plain `deviceorientation` is not guaranteed to be earth-relative on - // every platform — `deviceorientationabsolute` is, where it exists. const eventName = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation' const onOrientation = (event: DeviceOrientationEvent) => { @@ -101,8 +85,6 @@ export function useCompassHeading(active: boolean): CompassHeading { setAccuracy(typeof ios.webkitCompassAccuracy === 'number' && ios.webkitCompassAccuracy >= 0 ? ios.webkitCompassAccuracy : undefined) return } - // Plain deviceorientation may be relative to an arbitrary frame. It is - // not a compass unless the sample explicitly says it is Earth-relative. if (event.alpha != null && (eventName === 'deviceorientationabsolute' || event.absolute === true)) { setHeading((360 - event.alpha) % 360) setAccuracy(undefined) diff --git a/src/data/useFavorites.ts b/src/data/useFavorites.ts index 7c6247f5..e1e3821e 100644 --- a/src/data/useFavorites.ts +++ b/src/data/useFavorites.ts @@ -1,11 +1,22 @@ import { useCallback, useEffect, useState } from 'react' +import { DATA_YEAR } from '../config' -const KEY = 'playa-map.favorites.v1' +const KEY = `playa-map.favorites.v1.${DATA_YEAR}` + +export function parseFavorites(raw: string | null): Set { + if (!raw) return new Set() + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return new Set() + return new Set(parsed.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)) + } catch { + return new Set() + } +} function read(): Set { try { - const raw = localStorage.getItem(KEY) - return new Set(raw ? (JSON.parse(raw) as string[]) : []) + return parseFavorites(localStorage.getItem(KEY)) } catch { // Private windows and blocked site data both throw here. Favourites are a // convenience, so degrade to in-memory rather than breaking the map. diff --git a/src/data/usePlayaData.ts b/src/data/usePlayaData.ts index 01240435..92a47c40 100644 --- a/src/data/usePlayaData.ts +++ b/src/data/usePlayaData.ts @@ -9,14 +9,6 @@ import { applyEmbargo, embargoState, embargoWindowForYear, type EmbargoState } f import type { EventRange } from './events' import { DATA_YEAR, assetUrl } from '../config' -/** - * A dataset whose fetch failed and fell back to empty. `toilets`/`services`/ - * `dates` are safety- or schedule-relevant — silently showing a normal- - * looking map with zero toilets, or a schedule with no event window because - * `dates_info.json` never loaded, is worse than saying so. `outlines` is - * cosmetic camp-block geometry and is not tracked: it can fail without the - * user needing to know. - */ export type PartialDataWarning = 'toilets' | 'services' | 'dates' export interface PlayaData { @@ -26,16 +18,12 @@ export interface PlayaData { camps: CampItem[] events: EventItem[] pois: Poi[] - /** Listings with no location to show — see `UnplacedListing`. */ unplaced: UnplacedListing[] - /** The event week these listings describe, used to anchor "what's on now". */ range?: EventRange services: GeoJSON.FeatureCollection toilets: GeoJSON.FeatureCollection - /** Surveyed camp block footprints, drawn at high zoom. */ campOutlines: GeoJSON.FeatureCollection embargo: EmbargoState - /** Safety/schedule-relevant datasets that failed to load this attempt. */ partialDataWarnings: PartialDataWarning[] } @@ -59,16 +47,15 @@ export function usePlayaData() { setAttempt((current) => current + 1) }, []) - // The pre-embargo dataset from the most recent successful fetch, kept - // around so a release-boundary transition can be recomputed locally — - // `art`/`camps`/`pois`/`unplaced` all derive from this plus the current - // time — without going back to the network. `civic` (services/toilets/ - // landmarks) isn't embargoed and doesn't change across a transition, so - // it's cached alongside rather than recomputed. const rawRef = useRef< { layout: CityLayout; art: ArtItem[]; camps: CampItem[]; civic: Poi[] } | undefined >(undefined) const timerRef = useRef | undefined>(undefined) + // Every load gets a monotonically increasing generation. A later retry or + // DATA_REFRESHED message immediately makes every earlier completion stale, + // regardless of network completion order (#167). + const loadGenerationRef = useRef(0) + const mountedRef = useRef(true) const clearScheduledTransition = useCallback(() => { if (timerRef.current !== undefined) { @@ -77,16 +64,15 @@ export function usePlayaData() { } }, []) - /** - * Recompute embargo/art/camps/pois/unplaced from the cached pre-embargo - * dataset and arm a timer for whichever configured release boundary (camp - * release, then gates-open) hasn't happened yet — nothing is scheduled once - * both are released. Called once after a successful fetch and again, - * recursively, from the fired timer itself, so a session left open across - * both boundaries picks each one up without a reload. `cancelled` is the - * same guard `load()` uses for its own fetch, so a superseding load or an - * unmount stops this chain too. - */ + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + loadGenerationRef.current += 1 + clearScheduledTransition() + } + }, [clearScheduledTransition]) + const scheduleEmbargoTransition = useCallback( (cancelled: { current: boolean }) => { const embargoWindow = embargoWindowForYear(DATA_YEAR) @@ -94,7 +80,7 @@ export function usePlayaData() { function armNextBoundary() { clearScheduledTransition() const raw = rawRef.current - if (!raw || cancelled.current) return + if (!raw || cancelled.current || !mountedRef.current) return const current = embargoState(embargoWindow) const nextBoundary = !current.campsReleased ? embargoWindow.campRelease @@ -104,7 +90,7 @@ export function usePlayaData() { if (!nextBoundary) return timerRef.current = setTimeout(() => { - if (cancelled.current) return + if (cancelled.current || !mountedRef.current) return if (Date.now() < nextBoundary.getTime()) { armNextBoundary() return @@ -132,22 +118,14 @@ export function usePlayaData() { [clearScheduledTransition], ) - /** - * Shared by the initial/retry load and the silent background refresh - * below. `clear` decides whether to blank the screen back to the loading - * spinner first — appropriate for an explicit retry after a failure, wrong - * for a background refresh, which should keep showing the still-good data - * already on screen until (and unless) the new fetch actually succeeds. - */ const load = useCallback(() => { + const generation = ++loadGenerationRef.current const cancelled = { current: false } - // A new load — retry or background refresh — makes whatever boundary - // was pending from the previous load's dataset moot; it'll be re-armed - // below once this load's fetch succeeds. + const isCurrent = () => + mountedRef.current && !cancelled.current && generation === loadGenerationRef.current + clearScheduledTransition() const base = assetUrl(`data/${DATA_YEAR}`) - // Populated by `optional()` below as each safety/schedule-relevant fetch - // settles; read once every promise in the Promise.all has resolved. const partialDataWarnings: PartialDataWarning[] = [] const optional = (warning: PartialDataWarning, promise: Promise, fallback: T): Promise => promise.catch((cause) => { @@ -168,11 +146,10 @@ export function usePlayaData() { loadJson<{ rangeInfo?: EventRange }>(`${base}/dates_info.json`), {}, ), - // Cosmetic geometry only — no warning tracked for this one. loadJson(`${base}/city_blocks.geojson`).catch(() => empty()), ]) .then(([layout, rawArt, rawCamps, events, serviceSpecs, rawToilets, dates, outlines]) => { - if (cancelled.current) return + if (!isCurrent()) return const embargo = embargoState(embargoWindowForYear(DATA_YEAR)) const art = applyEmbargo(rawArt, embargo.artReleased) const camps = applyEmbargo(rawCamps, embargo.campsReleased) @@ -180,11 +157,9 @@ export function usePlayaData() { const listed = toPois(layout, art, camps, embargo) const services = buildServices(serviceSpecs) const toilets = toiletPoints(rawToilets) - // The survey's places share the index with the listings so that a tap - // on a ranger station resolves the same way a tap on a camp does. - // They are not embargoed: the city's own infrastructure is published - // with the survey, and only participants' locations are held back. const civic = civicPois(layout, services, toilets, city.landmarks) + // Commit raw and visible state under the same generation check. An old + // response can no longer overwrite either after a newer load wins. rawRef.current = { layout, art: rawArt, camps: rawCamps, civic } setData({ layout, @@ -205,26 +180,20 @@ export function usePlayaData() { scheduleEmbargoTransition(cancelled) }) .catch((cause: unknown) => { - if (!cancelled.current) { + if (isCurrent()) { setError(cause instanceof Error ? cause : new Error(String(cause))) } }) return () => { cancelled.current = true - clearScheduledTransition() + // A stale load's cleanup must not clear the timer armed by a newer one. + if (generation === loadGenerationRef.current) clearScheduledTransition() } }, [clearScheduledTransition, scheduleEmbargoTransition]) useEffect(() => load(), [attempt, load]) - // The service worker refreshes camp/event/listing data in the background - // and, once a complete revision has fetched cleanly, tells every open tab - // about it — otherwise a scheduled data update stayed invisible for the - // rest of whatever session was already open, even though the cache behind - // it had already moved on. Re-running the fetch now picks it up, since it - // is served from the (now-updated) cache rather than the network — without - // blanking the screen back to the loading state in the meantime. useEffect(() => { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return const onMessage = (event: MessageEvent) => { @@ -237,19 +206,8 @@ export function usePlayaData() { return { data, error, retry } } -/** - * Resolve every camp and art piece to a position. Published GPS wins; anything - * without it falls back to geocoding its address string, which is how records - * behave before the survey team publishes coordinates. - */ -/** - * How many art records it takes before "none of them has a location" means the - * data is old rather than empty. Burning Man publishes hundreds; a handful - * would not tell us anything either way. - */ const STALE_THRESHOLD = 20 -/** Exported for the tests: this is where the embargo decides what is reachable. */ export function toPois( layout: CityLayout, art: ArtItem[], @@ -276,19 +234,11 @@ export function toPois( address: item.location_string, position, positionSource: hasGps(item.location) ? 'gps' : 'address', - // Published API GPS is best-effort, not surveyed — Burning Man's own - // documentation says a camp/art piece can still move after - // Placement finishes publishing coordinates (#61). Only the GIS - // survey's own civic points (see civic.ts) earn 'surveyed'. accuracyClass: hasGps(item.location) ? 'published' : 'derived', thumbnail: item.images?.[0]?.thumbnail_url, }) return } - // Everything that cannot be placed used to be dropped here, silently, and - // with it went every art piece in the catalogue for the week before Gates. - // The listing is not the embargoed part; the location is, and there is - // none of it on this record to leak. unplaced.push({ uid: item.uid, kind, @@ -303,18 +253,6 @@ export function toPois( for (const item of art) sort(item, 'art', item.artist, embargo.artReleased) for (const item of camps) sort(item, 'camp', item.hometown, embargo.campsReleased) - /* - * Gates open on the clock, but the locations arrive over the network — and - * the people this app is for are the ones with no network. A phone that - * cached the city before Gates holds art records with every location stripped - * out, and the moment the embargo lifts it would have called all 329 of them - * "no location published", which is the opposite of what happened: they were - * published, and this copy is older than they are. - * - * Not one of hundreds having a location is not a catalogue with nothing in - * it, it is a snapshot from before. Said plainly, it also tells the reader - * the one thing that would fix it — a minute of signal. - */ markStaleSnapshot('camp', camps, embargo.campsReleased, pois, unplaced) markStaleSnapshot('art', art, embargo.artReleased, pois, unplaced) @@ -351,7 +289,6 @@ function resolve( return undefined } -/** Events joined to the camp or art piece that hosts them. */ export function useEventsByHost(data: PlayaData | undefined) { return useMemo(() => { const byHost = new Map() diff --git a/src/data/useSavedEvents.ts b/src/data/useSavedEvents.ts index 93375db3..8c88abe7 100644 --- a/src/data/useSavedEvents.ts +++ b/src/data/useSavedEvents.ts @@ -8,14 +8,17 @@ export interface SavedEvent { uid: string title: string savedAt: number + /** Stable upstream numeric identity used to detect uid reuse after refresh. */ + eventId?: number } -const MAX_TITLE_LENGTH = 300 - function isValidSavedEvent(candidate: Partial): candidate is SavedEvent { if (typeof candidate.uid !== 'string' || candidate.uid.trim().length === 0) return false - if (typeof candidate.title !== 'string' || candidate.title.trim().length === 0 || candidate.title.length > MAX_TITLE_LENGTH) return false - return typeof candidate.savedAt === 'number' && Number.isFinite(candidate.savedAt) + // Titles are display metadata, not storage identity. Do not impose a + // persistence-only length limit that upstream data does not share (#162). + if (typeof candidate.title !== 'string' || candidate.title.trim().length === 0) return false + if (typeof candidate.savedAt !== 'number' || !Number.isFinite(candidate.savedAt)) return false + return candidate.eventId === undefined || (typeof candidate.eventId === 'number' && Number.isFinite(candidate.eventId)) } export function parseSavedEvents(raw: string | null): SavedEvent[] { @@ -33,6 +36,17 @@ export function parseSavedEvents(raw: string | null): SavedEvent[] { }) } +/** + * Reconcile a persisted bookmark with live data without letting a reused uid + * silently transfer user intent to another event (#164). New saves carry the + * stable numeric event_id. Legacy saves fall back to exact title continuity; + * ambiguity degrades to the existing stale row rather than guessing. + */ +export function savedEventMatches(saved: SavedEvent, event: EventItem): boolean { + if (saved.uid !== event.uid) return false + return saved.eventId !== undefined ? saved.eventId === event.event_id : saved.title === event.title +} + function read(): SavedEvent[] { try { return parseSavedEvents(localStorage.getItem(KEY)) } catch { return [] } } @@ -61,7 +75,7 @@ export function useSavedEvents() { const save = useCallback((event: EventItem): boolean => { const next = savedEventsRef.current.some((item) => item.uid === event.uid) ? savedEventsRef.current - : [{ uid: event.uid, title: event.title, savedAt: Date.now() }, ...savedEventsRef.current] + : [{ uid: event.uid, title: event.title, eventId: event.event_id, savedAt: Date.now() }, ...savedEventsRef.current] return commit(next) }, [commit]) diff --git a/src/data/useSavedPlaces.ts b/src/data/useSavedPlaces.ts index 8389e9a5..08c6889d 100644 --- a/src/data/useSavedPlaces.ts +++ b/src/data/useSavedPlaces.ts @@ -28,14 +28,20 @@ export interface SavedPlaceResult { persisted: boolean } -const MAX_NAME_LENGTH = 200 +export const MAX_SAVED_PLACE_NAME_LENGTH = 200 + +export function normalizeSavedPlaceName(name: string): string | undefined { + const trimmed = name.trim() + if (!trimmed) return undefined + return trimmed.slice(0, MAX_SAVED_PLACE_NAME_LENGTH) +} function isValidPlace(candidate: Partial): candidate is SavedPlace { if (typeof candidate.id !== 'string' || candidate.id.trim().length === 0) return false if ( typeof candidate.name !== 'string' || candidate.name.trim().length === 0 || - candidate.name.length > MAX_NAME_LENGTH + candidate.name.length > MAX_SAVED_PLACE_NAME_LENGTH ) return false if (typeof candidate.address !== 'string') return false if (typeof candidate.savedAt !== 'number' || !Number.isFinite(candidate.savedAt)) return false @@ -91,9 +97,12 @@ export function useSavedPlaces() { }, []) const save = useCallback((name: string, position: Position, address: string): SavedPlaceResult => { + // Storage writes must satisfy the same parser that will read them on the + // next launch. UI input is capped too, but this keeps non-UI callers safe. + const normalizedName = normalizeSavedPlaceName(name) ?? 'Saved place' const place: SavedPlace = { id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`, - name, + name: normalizedName, position, address, savedAt: Date.now(), @@ -113,7 +122,9 @@ export function useSavedPlaces() { }, [commit]) const rename = useCallback((id: string, name: string): boolean => { - return commit(placesRef.current.map((place) => (place.id === id ? { ...place, name } : place))) + const normalizedName = normalizeSavedPlaceName(name) + if (!normalizedName) return false + return commit(placesRef.current.map((place) => (place.id === id ? { ...place, name: normalizedName } : place))) }, [commit]) return { places, save, remove, restore, rename } diff --git a/src/data/useWakeLock.ts b/src/data/useWakeLock.ts index 0975772d..ad1b2a30 100644 --- a/src/data/useWakeLock.ts +++ b/src/data/useWakeLock.ts @@ -12,43 +12,61 @@ export type WakeLockState = 'unsupported' | 'inactive' | 'active' */ export function useWakeLock(active: boolean): WakeLockState { const lock = useRef(null) + const lifecycle = useRef(0) const [state, setState] = useState(() => typeof navigator === 'undefined' || !('wakeLock' in navigator) ? 'unsupported' : 'inactive', ) useEffect(() => { - if (state === 'unsupported' || !active) return + const generation = ++lifecycle.current + if (state === 'unsupported') return + if (!active) { + // Effects must not synchronously cascade state during render commit, but + // a queued reset still needs lifecycle ownership: a rapid stop -> start + // must not let the old stop overwrite a newly acquired lock's state. + queueMicrotask(() => { + if (lifecycle.current === generation) setState('inactive') + }) + return + } + let cancelled = false + let acquiring = false const acquire = async () => { + // `lock.current` is populated only after request() resolves. Without a + // separate in-flight guard, a visibility event can start request B while + // request A is pending and orphan A when B later overwrites the ref (#166). + if (cancelled || acquiring || lock.current) return + acquiring = true try { const sentinel = await navigator.wakeLock.request('screen') - if (cancelled) { - // `active` already went false, or this effect re-ran, while the - // request was in flight — release immediately rather than leaving - // a lock nothing here still references. + if (cancelled || lifecycle.current !== generation) { + void sentinel.release() + return + } + // Defensive: if the platform somehow resolved another sentinel while + // this request was pending, never replace it without releasing this one. + if (lock.current) { void sentinel.release() return } lock.current = sentinel setState('active') sentinel.addEventListener('release', () => { - lock.current = null - if (!cancelled) setState('inactive') + if (lock.current === sentinel) lock.current = null + if (!cancelled && lifecycle.current === generation) setState('inactive') }) } catch { - // Rejected — battery saver, a hidden tab, no user activation yet. - // Navigation keeps working exactly as it did before this existed. - setState('inactive') + if (!cancelled && lifecycle.current === generation) setState('inactive') + } finally { + acquiring = false } } void acquire() - // The OS/browser revokes the lock the moment the tab is hidden, with no - // "still wants it" signal beyond that revocation — so this reacquires - // whenever the tab becomes visible again while navigation is still active. const onVisibilityChange = () => { - if (document.visibilityState === 'visible' && !lock.current) void acquire() + if (document.visibilityState === 'visible') void acquire() } document.addEventListener('visibilitychange', onVisibilityChange) @@ -57,11 +75,10 @@ export function useWakeLock(active: boolean): WakeLockState { document.removeEventListener('visibilitychange', onVisibilityChange) const sentinel = lock.current lock.current = null - setState('inactive') if (sentinel) void sentinel.release() } - // eslint-disable-next-line react-hooks/exhaustive-deps -- `state` is only read to bail out on 'unsupported'; including it would tear the lock down every time this effect itself flips it to 'active'. + // eslint-disable-next-line react-hooks/exhaustive-deps -- `state` is only read to bail out on the immutable initial 'unsupported' capability; including it would tear the lock down whenever this effect reports 'active'. }, [active]) - return state + return active ? state : state === 'unsupported' ? 'unsupported' : 'inactive' } diff --git a/src/ui/DirectionsPanel.tsx b/src/ui/DirectionsPanel.tsx index 1a98cd00..a051b3f1 100644 --- a/src/ui/DirectionsPanel.tsx +++ b/src/ui/DirectionsPanel.tsx @@ -26,7 +26,7 @@ interface Props { open: boolean; compact: boolean; layout: CityLayout; pois: readonly Poi[]; events: readonly EventItem[]; places: readonly SavedPlace[]; droppedPin?: { position: [number, number]; address: string }; from: DirectionsEndpoint; to?: DirectionsEndpoint; mode: DirectionsMode; hasUsableLiveFix: boolean; - findingLocation: boolean; preview?: DirectionsPreview; + findingLocation: boolean; destinationResolved: boolean; preview?: DirectionsPreview; onFromChange: (endpoint: DirectionsEndpoint) => void; onToChange: (endpoint: DirectionsEndpoint | undefined) => void; onModeChange: (mode: DirectionsMode) => void; onSwap: () => void; onStart: () => void; onShare: () => void; onShareImage: () => void; onClose: () => void; @@ -63,17 +63,29 @@ function baseOptions(pois: readonly Poi[], events: readonly EventItem[], places: } function EndpointPicker({ label, value, options, layout, pois, disableLive, clearable = true, onChange }: { label: string; value?: DirectionsEndpoint; options: readonly EndpointOption[]; layout: CityLayout; pois: readonly Poi[]; disableLive: boolean; clearable?: boolean; onChange: (endpoint: DirectionsEndpoint | undefined) => void }) { - const [query, setQuery] = useState('') + // This component itself is keyed by endpoint identity at the callsite. That + // gives programmatic swaps a fresh input initialized from the new endpoint, + // while a human editing the *same* endpoint owns this controlled string until + // they choose a replacement. Keeping only the Autocomplete child keyed left + // this state outside the remount boundary and let MUI reset newly typed text + // back to the previous selected address in a real mobile browser (#169). + const [inputValue, setInputValue] = useState(() => value ? directionsEndpointLabel(value, pois) : '') const dynamicOptions = useMemo(() => { - const trimmed = query.trim(); if (trimmed.length < 2) return options + const trimmed = inputValue.trim(); if (trimmed.length < 2) return options const result = geocode(trimmed, layout); if (!result) return options return [{ key: `address:${result.label}`, label: result.label, detail: 'Playa address', endpoint: { kind: 'address' as const, address: result.label, position: result.position } }, ...options] - }, [layout, options, query]) + }, [inputValue, layout, options]) const selected = value ? dynamicOptions.find((option) => option.key === optionKey(value)) ?? dynamicOptions.find((option) => optionKey(option.endpoint) === optionKey(value)) ?? { key: optionKey(value), label: directionsEndpointLabel(value, pois), detail: '', endpoint: value } : null - return setQuery(reason === 'input' ? next : '')} getOptionLabel={(option) => option.label} + onInputChange={(_, next, reason) => { + // `reset` is MUI synchronizing the text back to the still-selected value. + // During a human re-edit that is exactly the stale snap-back from #169. + // A real endpoint change remounts this picker via its callsite key, so + // selection/programmatic synchronization does not depend on reset events. + if (reason === 'input' || reason === 'clear') setInputValue(next) + }} getOptionLabel={(option) => option.label} getOptionDisabled={(option) => disableLive && option.endpoint.kind === 'live'} isOptionEqualToValue={(a, b) => a.key === b.key} filterOptions={(items, state) => { const raw = state.inputValue.trim() @@ -93,7 +105,7 @@ function routeSemantics(kind: PlayaRoute['kind']): string { return 'Straight-line bearing guidance — verify a walkable path around occupied blocks.' } -export function DirectionsPanel({ open, compact, layout, pois, events, places, droppedPin, from, to, mode, hasUsableLiveFix, findingLocation, preview, onFromChange, onToChange, onModeChange, onSwap, onStart, onShare, onShareImage, onClose }: Props) { +export function DirectionsPanel({ open, compact, layout, pois, events, places, droppedPin, from, to, mode, hasUsableLiveFix, findingLocation, destinationResolved, preview, onFromChange, onToChange, onModeChange, onSwap, onStart, onShare, onShareImage, onClose }: Props) { const options = useMemo(() => baseOptions(pois, events, places, droppedPin, hasUsableLiveFix, layout), [pois, events, places, droppedPin, hasUsableLiveFix, layout]) const fromLabel = directionsEndpointLabel(from, pois) const canStart = Boolean(to && preview) && (from.kind !== 'live' || hasUsableLiveFix) @@ -102,14 +114,15 @@ export function DirectionsPanel({ open, compact, layout, pois, events, places, d const content = DirectionsPoint-to-point planning stays on this device and works offline. - endpoint && onFromChange(endpoint)} /> - + endpoint && onFromChange(endpoint)} /> + {from.kind === 'live' && {hasUsableLiveFix ? `${fromLabel} follows your live GPS position.` : findingLocation ? 'Finding your location… You can choose a destination while GPS starts.' : 'Your location is unavailable here. Choose The Man or another fixed start.'}} value && onModeChange(value)} aria-label="Travel mode"> Walk Bike {preview && eta !== undefined && {formatDistance(preview.travel)}{formatMinutes(eta)}{mode === 'walk' ? 'Walk' : 'Bike'} · head toward {preview.heading}{routeSemantics(preview.route.kind)}Start: {preview.fromLabel}{preview.route.kind === 'direct' ? `Continue toward ${preview.heading} using bearing guidance.` : preview.route.kind === 'hybrid' ? 'Use surveyed streets around occupied blocks, then continue across open playa.' : 'Use the surveyed radial and annular streets around occupied blocks.'}Arrive: {preview.toDetail ?? preview.toLabel}} - {!preview && to && from.kind !== 'live' && One of these endpoints cannot be resolved against the current map data.} - + {to && !destinationResolved && Destination is no longer in the current map or cannot be resolved.} + {!preview && to && destinationResolved && from.kind !== 'live' && The start location cannot be resolved against the current map data.} + return compact ? {content} : {content} diff --git a/src/ui/EventsPanel.tsx b/src/ui/EventsPanel.tsx index 9175157d..2449fd78 100644 --- a/src/ui/EventsPanel.tsx +++ b/src/ui/EventsPanel.tsx @@ -36,7 +36,7 @@ import { formatDistance, travelBetween } from '../brc/travel' import type { Position } from '../brc/geo' import type { CityLayout } from '../brc/layout' import type { LocationStatus } from '../data/useGeolocation' -import type { SavedEvent } from '../data/useSavedEvents' +import { savedEventMatches, type SavedEvent } from '../data/useSavedEvents' interface Props { open: boolean @@ -190,7 +190,7 @@ export function EventsPanel({ const live: LiveEvent[] = [] for (const saved of savedEvents) { const event = byUid.get(saved.uid) - if (!event) continue + if (!event || !savedEventMatches(saved, event)) continue const relevant = relevantOccurrence(event, now) if (!relevant) continue live.push({ @@ -213,11 +213,13 @@ export function EventsPanel({ */ const missingSavedEvents = useMemo(() => { if (window !== 'saved') return [] - const presentUids = new Set(events.map((event) => event.uid)) + const byUid = new Map(events.map((event) => [event.uid, event] as const)) const term = query.trim().toLowerCase() - return savedEvents.filter( - (saved) => !presentUids.has(saved.uid) && (!term || saved.title.toLowerCase().includes(term)), - ) + return savedEvents.filter((saved) => { + const current = byUid.get(saved.uid) + const missingOrReused = !current || !savedEventMatches(saved, current) + return missingOrReused && (!term || saved.title.toLowerCase().includes(term)) + }) }, [window, events, savedEvents, query]) const matching = useMemo(() => { diff --git a/src/ui/SavePlaceDialog.tsx b/src/ui/SavePlaceDialog.tsx index 954e0d87..38c23038 100644 --- a/src/ui/SavePlaceDialog.tsx +++ b/src/ui/SavePlaceDialog.tsx @@ -10,6 +10,7 @@ import { Typography, Chip, } from '@mui/material' +import { MAX_SAVED_PLACE_NAME_LENGTH } from '../data/useSavedPlaces' interface Props { open: boolean @@ -59,6 +60,8 @@ export function SavePlaceDialog({ open, address, onSave, onClose }: Props) { value={name} onChange={(event) => setName(event.target.value)} onKeyDown={(event) => event.key === 'Enter' && commit()} + slotProps={{ htmlInput: { maxLength: MAX_SAVED_PLACE_NAME_LENGTH } }} + helperText={`${name.length}/${MAX_SAVED_PLACE_NAME_LENGTH}`} sx={{ mt: 1 }} /> diff --git a/src/ui/__tests__/ArtAudioGuide.test.tsx b/src/ui/__tests__/ArtAudioGuide.test.tsx new file mode 100644 index 00000000..e3005067 --- /dev/null +++ b/src/ui/__tests__/ArtAudioGuide.test.tsx @@ -0,0 +1,63 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ArtAudioGuide } from '../ArtAudioGuide' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} + +describe('ArtAudioGuide UID isolation (#100)', () => { + const indexFetch = deferred() + const revokeObjectURL = vi.fn() + const createObjectURL = vi.fn(() => 'blob:art-a') + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(() => indexFetch.promise)) + Object.defineProperty(URL, 'createObjectURL', { value: createObjectURL, configurable: true }) + Object.defineProperty(URL, 'revokeObjectURL', { value: revokeObjectURL, configurable: true }) + const cache = { + match: vi.fn((key: string) => + key.endsWith('/art-a.mp3') + ? Promise.resolve(new Response(new Blob(['audio-a']), { headers: { 'content-length': '7' } })) + : Promise.resolve(undefined), + ), + put: vi.fn(), + delete: vi.fn(), + } + Object.defineProperty(window, 'caches', { + value: { open: vi.fn(() => Promise.resolve(cache)) }, + configurable: true, + }) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.restoreAllMocks() + // @ts-expect-error test-only cleanup + delete window.caches + }) + + it('removes and revokes art A audio before uncached art B resolves', async () => { + const { container, rerender, unmount } = render() + + await waitFor(() => { + const audio = container.querySelector('audio') + expect(audio?.getAttribute('src')).toBe('blob:art-a') + }) + + rerender() + await waitFor(() => expect(container.querySelector('audio')).toBeNull()) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:art-a') + + // B's ZIP index request is intentionally still pending: the old player is + // already gone before any result for B exists. + expect(indexFetch.resolve).toBeTypeOf('function') + unmount() + }) +}) diff --git a/src/ui/__tests__/DirectionsPanel.test.tsx b/src/ui/__tests__/DirectionsPanel.test.tsx index 06cd705e..94f675b4 100644 --- a/src/ui/__tests__/DirectionsPanel.test.tsx +++ b/src/ui/__tests__/DirectionsPanel.test.tsx @@ -36,6 +36,7 @@ const baseProps = { mode: 'walk' as const, hasUsableLiveFix: false, findingLocation: false, + destinationResolved: true, onFromChange: vi.fn(), onToChange: vi.fn(), onModeChange: vi.fn(), @@ -104,6 +105,51 @@ describe('DirectionsPanel', () => { expect(await screen.findByRole('option', { name: /Open Playa Meetup/i })).toBeDefined() }) + it('keeps immediate replacement text under user control after selecting an address (#169)', async () => { + const camp: Poi = { + uid: 'camp-airship', + kind: 'camp', + name: 'The Airship', + address: '4:30 & D', + position: [-119.19, 40.785], + positionSource: 'address', + accuracyClass: 'derived', + } + const onToChange = vi.fn() + const address = { + kind: 'address' as const, + address: 'Esplanade & 7:30', + position: [-119.2, 40.78] as [number, number], + } + const { rerender } = render( + , + ) + + const to = screen.getByRole('combobox', { name: 'To' }) as HTMLInputElement + expect(to.value).toBe('Esplanade & 7:30') + + fireEvent.change(to, { target: { value: 'The Airship' } }) + expect(to.value).toBe('The Airship') + const option = await screen.findByRole('option', { name: /The Airship/i }) + fireEvent.click(option) + expect(onToChange).toHaveBeenCalledWith({ kind: 'poi', uid: camp.uid }) + + rerender( + , + ) + expect((screen.getByRole('combobox', { name: 'To' }) as HTMLInputElement).value).toBe('The Airship') + }) + it('refreshes visible endpoint labels when From and To are swapped programmatically', () => { const destination = { kind: 'fixed' as const, label: 'The Airship', position: [-119.19, 40.785] as [number, number] } const { rerender } = render( diff --git a/src/ui/__tests__/DirectionsPanelResolution.test.tsx b/src/ui/__tests__/DirectionsPanelResolution.test.tsx new file mode 100644 index 00000000..b1f439fa --- /dev/null +++ b/src/ui/__tests__/DirectionsPanelResolution.test.tsx @@ -0,0 +1,117 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CityLayout } from '../../brc/layout' +import type { PlayaRoute } from '../../brc/routing' +import { DirectionsPanel } from '../DirectionsPanel' + +const layout: CityLayout = { + center: { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-119.2, 40.78] } }, + bearing: 45, + fence_distance: 10000, + road_width: 40, + cStreets: [], + tStreets: [], + plazas: [], + portals: [], +} + +const route: PlayaRoute = { + kind: 'direct', + meters: 100, + coordinates: [[-119.2, 40.78], [-119.199, 40.781]], +} + +const base = { + open: true, + compact: false, + layout, + pois: [], + events: [], + places: [], + mode: 'walk' as const, + findingLocation: false, + onFromChange: vi.fn(), + onToChange: vi.fn(), + onModeChange: vi.fn(), + onSwap: vi.fn(), + onStart: vi.fn(), + onShare: vi.fn(), + onShareImage: vi.fn(), + onClose: vi.fn(), +} + +afterEach(cleanup) + +describe('Directions destination resolution (#160)', () => { + it('shows a stale destination for a live origin and refuses to re-share it', () => { + render( + , + ) + + expect(screen.getByText(/Destination is no longer in the current map/i)).toBeDefined() + expect((screen.getByRole('button', { name: 'Share link' }) as HTMLButtonElement).disabled).toBe(true) + expect((screen.getByRole('button', { name: 'Route card' }) as HTMLButtonElement).disabled).toBe(true) + expect((screen.getByRole('button', { name: 'Start navigation' }) as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByText(/follows your live GPS position/i)).toBeDefined() + }) + + it('shows the same destination failure for a fixed origin', () => { + render( + , + ) + expect(screen.getByText(/Destination is no longer in the current map/i)).toBeDefined() + expect((screen.getByRole('button', { name: 'Share link' }) as HTMLButtonElement).disabled).toBe(true) + }) + + it('keeps GPS-unavailable and destination-invalid states distinct', () => { + render( + , + ) + expect(screen.getByText(/Your location is unavailable here/i)).toBeDefined() + expect(screen.queryByText(/Destination is no longer/i)).toBeNull() + expect((screen.getByRole('button', { name: 'Share link' }) as HTMLButtonElement).disabled).toBe(false) + }) + + it('enables route actions only when the destination and route resolve', () => { + render( + , + ) + expect((screen.getByRole('button', { name: 'Share link' }) as HTMLButtonElement).disabled).toBe(false) + expect((screen.getByRole('button', { name: 'Start navigation' }) as HTMLButtonElement).disabled).toBe(false) + }) +}) diff --git a/src/ui/__tests__/EventsPanelSavedIdentity.test.tsx b/src/ui/__tests__/EventsPanelSavedIdentity.test.tsx new file mode 100644 index 00000000..2d4c25fa --- /dev/null +++ b/src/ui/__tests__/EventsPanelSavedIdentity.test.tsx @@ -0,0 +1,81 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CityLayout } from '../../brc/layout' +import type { EventItem } from '../../data/types' +import { EventsPanel } from '../EventsPanel' + +const layout: CityLayout = { + center: { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [-119.2, 40.78] } }, + bearing: 45, + fence_distance: 10560, + road_width: 40, + cStreets: [], + tStreets: [], + plazas: [], + portals: [], +} + +function event(title: string, eventId: number): EventItem { + return { + uid: 'reused-uid', + title, + event_id: eventId, + year: 2026, + occurrence_set: [{ start_time: '2026-09-02T10:00:00-07:00', end_time: '2026-09-02T11:00:00-07:00' }], + } +} + +const base = { + open: true, + hosts: new Map(), + layout, + now: new Date('2026-09-02T10:30:00-07:00'), + preview: false, + origin: undefined, + locationStatus: 'idle' as const, + onSelectEvent: vi.fn(), + onClose: vi.fn(), + onNeedLocation: vi.fn(), + onDoneWithLocation: vi.fn(), + isEventSaved: () => true, + onToggleSaveEvent: vi.fn(), + onRemoveSavedEvent: vi.fn(), +} + +afterEach(cleanup) + +describe('EventsPanel saved identity continuity (#164)', () => { + it('renders a reused uid as stale instead of silently binding the bookmark to the new event', () => { + const current = event('Midnight Fire Jam', 99) + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /^saved$/i })) + + expect(screen.getByText('Morning Coffee')).toBeDefined() + expect(screen.getByText(/no longer listed this year/i)).toBeDefined() + expect(screen.queryByText('Midnight Fire Jam')).toBeNull() + }) + + it('keeps a normal title update saved when event_id proves identity continuity', () => { + const current = event('Morning Coffee — updated details', 42) + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /^saved$/i })) + + expect(screen.getByText('Morning Coffee — updated details')).toBeDefined() + expect(screen.queryByText(/no longer listed this year/i)).toBeNull() + }) +})