Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
43370e8
Scope favorites to the active data year (#163)
lnorton89 Aug 26, 2026
ace13d1
Make saved-event persistence round-trip safe (#162 #164)
lnorton89 Aug 26, 2026
97c1e3a
Enforce saved-place name limit on writes (#161)
lnorton89 Aug 26, 2026
c51a2a9
Cap saved-place names in the dialog (#161)
lnorton89 Aug 26, 2026
6f70cc4
Invalidate stale compass samples between routes (#165)
lnorton89 Aug 26, 2026
b51dab7
Prevent overlapping wake-lock acquisitions (#166)
lnorton89 Aug 26, 2026
9c4b35e
Make data reloads newest-wins (#167)
lnorton89 Aug 26, 2026
f9104f8
Expose unresolved Directions destinations (#160)
lnorton89 Aug 26, 2026
24063d6
Add one-shot codemod for remaining open issues
lnorton89 Aug 26, 2026
86cd074
Run one-shot remaining-issues codemod
lnorton89 Aug 26, 2026
5d5de1c
Fix unresolved directions and saved-event identity (#160 #164)
github-actions[bot] Aug 26, 2026
3411133
Test unresolved Directions destination handling (#160)
lnorton89 Aug 26, 2026
06f270a
Remove one-shot issue codemod workflow
lnorton89 Aug 26, 2026
a7bb448
Remove one-shot issue codemod script
lnorton89 Aug 26, 2026
e9e6636
Test persistence invariants for saved data (#161–#164)
lnorton89 Aug 26, 2026
bf5db5c
Test compass and wake-lock lifecycle races (#165 #166)
lnorton89 Aug 26, 2026
dc4e1c9
Test newest-wins live data reloads (#167)
lnorton89 Aug 26, 2026
9108691
Test art-audio UID isolation (#100)
lnorton89 Aug 26, 2026
c16d9b1
Update Directions panel tests for resolution state (#160)
lnorton89 Aug 26, 2026
11a0b75
Assert stale compass samples are cleared on stop (#165)
lnorton89 Aug 26, 2026
703cfd0
Tighten persistence regression typing and year coverage
lnorton89 Aug 26, 2026
986ca1b
Test saved-event UID reuse handling (#164)
lnorton89 Aug 26, 2026
5a8dd6c
Guard wake-lock state across rapid lifecycle changes (#166)
lnorton89 Aug 26, 2026
d1619f5
chore: trigger final PR validation
lnorton89 Aug 26, 2026
a982df7
Keep Directions endpoint edits under user control (#169)
lnorton89 Aug 26, 2026
6be5de7
Add Directions immediate re-edit regression coverage (#169)
lnorton89 Aug 27, 2026
af7c8a5
Fix smoke test for year-scoped favorites (#163)
lnorton89 Aug 27, 2026
caa2c09
Ignore Autocomplete selection resets while editing (#169)
lnorton89 Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 16 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions src/data/__tests__/navigationLifecycleRegressions.test.tsx
Original file line number Diff line number Diff line change
@@ -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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<WakeLockSentinel>()
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<WakeLockSentinel>()
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)
})
})
121 changes: 121 additions & 0 deletions src/data/__tests__/persistenceInvariants.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading