mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(dawarich): three things a tester on dev1 ran into
The Atlas country check timed out. It asked Dawarich for a year of visited cities in one request, and Dawarich computes that over every point in the window with nothing cached, so a dense recorder never got under the 15 s the server allows. The year is now asked for a month at a time, one request after another, and folded back together. The windows sit a second apart because Dawarich reads the bounds as inclusive. The recorded route only appeared after a reload. Every Dawarich call from the browser carried the shared 8 s timeout, so the browser gave up while the server carried on and cached the answer for the next page load. Calls that make the server talk to Dawarich now wait two minutes, the Atlas four. On the GL renderers the draw also gave up whenever isStyleLoaded() was false, which it is while any tile is still loading, and only retried on style.load. It now waits for idle. The hazard overlay had the same guard and gets the same wait. The pill shows a ring while the route loads, so a slow first fetch no longer looks like "done, nothing there". A collapsed day now takes its recorded route off the map, following the same folds the planner already applies to places, the road trip sidebar's included.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* FE-DAWARICH-API-001 to FE-DAWARICH-API-019
|
* FE-DAWARICH-API-001 to FE-DAWARICH-API-019, plus FE-DAWARICH-API-040 and -041
|
||||||
|
* for how long the browser waits.
|
||||||
*
|
*
|
||||||
* `dawarichApi` is one line per endpoint: a verb, a URL, and the shape of what
|
* `dawarichApi` is one line per endpoint: a verb, a URL, and the shape of what
|
||||||
* goes into the body or the query string. There is no logic in it to get wrong,
|
* goes into the body or the query string. There is no logic in it to get wrong,
|
||||||
@@ -30,7 +31,8 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
import { http, HttpResponse, delay, type JsonBodyType } from 'msw'
|
import { http, HttpResponse, delay, type JsonBodyType } from 'msw'
|
||||||
import { server } from '../../tests/helpers/msw/server'
|
import { server } from '../../tests/helpers/msw/server'
|
||||||
import { dawarichApi } from './dawarich'
|
import { dawarichApi, DAWARICH_ATLAS_TIMEOUT_MS, DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich'
|
||||||
|
import apiClient from './client'
|
||||||
import type {
|
import type {
|
||||||
DawarichAtlasSuggestions,
|
DawarichAtlasSuggestions,
|
||||||
DawarichBucketScan,
|
DawarichBucketScan,
|
||||||
@@ -404,3 +406,54 @@ describe('dawarichApi track overlay', () => {
|
|||||||
expect(query().get('offset')).toBe('0')
|
expect(query().get('offset')).toBe('0')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('dawarichApi: how long the browser waits', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DAWARICH-API-040: every call that makes the server ask Dawarich outlasts the shared 8 s', async () => {
|
||||||
|
// The server gives each request to Dawarich 15 s on its own. At the shared
|
||||||
|
// 8 s the browser gave up first, the server cached the answer anyway, and the
|
||||||
|
// recorded route turned up only after a reload.
|
||||||
|
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
|
||||||
|
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
|
||||||
|
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
await dawarichApi.tripTrack(7)
|
||||||
|
await dawarichApi.windowTrack('2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z')
|
||||||
|
await dawarichApi.test({ url: 'https://dawarich.example' })
|
||||||
|
await dawarichApi.syncNow()
|
||||||
|
await dawarichApi.scanBucketList()
|
||||||
|
await dawarichApi.saveSettings({ url: 'https://dawarich.example', allowInsecureTls: false, syncEnabled: true })
|
||||||
|
|
||||||
|
const configs = [
|
||||||
|
...get.mock.calls.map(call => call[1]),
|
||||||
|
...post.mock.calls.map(call => call[2]),
|
||||||
|
...put.mock.calls.map(call => call[2]),
|
||||||
|
]
|
||||||
|
expect(configs).toHaveLength(6)
|
||||||
|
for (const config of configs) {
|
||||||
|
expect(config).toEqual(expect.objectContaining({ timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }))
|
||||||
|
}
|
||||||
|
expect(DAWARICH_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(15_000)
|
||||||
|
// The track calls still carry their offset alongside the longer wait.
|
||||||
|
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ params: expect.objectContaining({ offset: expect.any(Number) }) }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DAWARICH-API-041: the Atlas waits longest, and the purely local calls keep the shared default', async () => {
|
||||||
|
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
|
||||||
|
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
|
||||||
|
|
||||||
|
await dawarichApi.atlasSuggestions('2025-09-14T00:00:00Z', '2026-09-14T00:00:00Z')
|
||||||
|
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ timeout: DAWARICH_ATLAS_TIMEOUT_MS }))
|
||||||
|
expect(DAWARICH_ATLAS_TIMEOUT_MS).toBeGreaterThan(DAWARICH_UPSTREAM_TIMEOUT_MS)
|
||||||
|
|
||||||
|
// Reading suggestions and confirming countries touch TREK's own database
|
||||||
|
// only; a two-minute wait there would just hide a stuck server.
|
||||||
|
await dawarichApi.listSuggestions()
|
||||||
|
await dawarichApi.acceptAtlasCountries(['DE'])
|
||||||
|
expect(get.mock.calls[1][1]).not.toEqual(expect.objectContaining({ timeout: expect.anything() }))
|
||||||
|
expect(post.mock.calls[0][2]).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ import type {
|
|||||||
* client has not caught up with is a typecheck failure rather than a runtime
|
* client has not caught up with is a typecheck failure rather than a runtime
|
||||||
* surprise.
|
* surprise.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* How long the browser waits for a call that makes the server go and ask
|
||||||
|
* Dawarich.
|
||||||
|
*
|
||||||
|
* The shared 8 s on `apiClient` is right for TREK's own routes and wrong for
|
||||||
|
* these: the server allows each request to Dawarich 15 s by itself, walks pages
|
||||||
|
* of points for a route, and runs six probes one after another to test a
|
||||||
|
* connection. Cut off at 8 s, the browser gave up while the server carried on
|
||||||
|
* and cached the answer, so the recorded route showed "unavailable" and then
|
||||||
|
* appeared on the next reload.
|
||||||
|
*/
|
||||||
|
export const DAWARICH_UPSTREAM_TIMEOUT_MS = 120_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Atlas asks for a year of visited cities a month at a time, one request
|
||||||
|
* after another, so it gets longer again. Still bounded, and under Node's own
|
||||||
|
* five-minute request limit on the server side.
|
||||||
|
*/
|
||||||
|
export const DAWARICH_ATLAS_TIMEOUT_MS = 240_000
|
||||||
|
|
||||||
|
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }
|
||||||
|
|
||||||
export const dawarichApi = {
|
export const dawarichApi = {
|
||||||
// ── Connection ─────────────────────────────────────────────────────────────
|
// ── Connection ─────────────────────────────────────────────────────────────
|
||||||
getSettings: (): Promise<DawarichConnection> =>
|
getSettings: (): Promise<DawarichConnection> =>
|
||||||
@@ -31,13 +53,13 @@ export const dawarichApi = {
|
|||||||
/** The address it resolved to, for the private-IP warning. */
|
/** The address it resolved to, for the private-IP warning. */
|
||||||
warningIp?: string
|
warningIp?: string
|
||||||
}> =>
|
}> =>
|
||||||
apiClient.put('/integrations/dawarich/settings', data).then(r => r.data),
|
apiClient.put('/integrations/dawarich/settings', data, upstream).then(r => r.data),
|
||||||
disconnect: (): Promise<{ success: boolean }> =>
|
disconnect: (): Promise<{ success: boolean }> =>
|
||||||
apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
|
apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
|
||||||
test: (data: Partial<DawarichSettings>): Promise<DawarichStatus> =>
|
test: (data: Partial<DawarichSettings>): Promise<DawarichStatus> =>
|
||||||
apiClient.post('/integrations/dawarich/test', data).then(r => r.data),
|
apiClient.post('/integrations/dawarich/test', data, upstream).then(r => r.data),
|
||||||
syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> =>
|
syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> =>
|
||||||
apiClient.post('/integrations/dawarich/sync').then(r => r.data),
|
apiClient.post('/integrations/dawarich/sync', undefined, upstream).then(r => r.data),
|
||||||
|
|
||||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||||
listSuggestions: (params?: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> =>
|
listSuggestions: (params?: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> =>
|
||||||
@@ -49,7 +71,7 @@ export const dawarichApi = {
|
|||||||
|
|
||||||
// ── Bucket list ────────────────────────────────────────────────────────────
|
// ── Bucket list ────────────────────────────────────────────────────────────
|
||||||
scanBucketList: (): Promise<DawarichBucketScan> =>
|
scanBucketList: (): Promise<DawarichBucketScan> =>
|
||||||
apiClient.post('/integrations/dawarich/bucket-list/scan').then(r => r.data),
|
apiClient.post('/integrations/dawarich/bucket-list/scan', undefined, upstream).then(r => r.data),
|
||||||
confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> =>
|
confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> =>
|
||||||
apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
|
apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
|
||||||
clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
|
clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
|
||||||
@@ -57,7 +79,9 @@ export const dawarichApi = {
|
|||||||
|
|
||||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||||
atlasSuggestions: (from: string, to: string): Promise<DawarichAtlasSuggestions> =>
|
atlasSuggestions: (from: string, to: string): Promise<DawarichAtlasSuggestions> =>
|
||||||
apiClient.get('/integrations/dawarich/atlas/suggestions', { params: { from, to } }).then(r => r.data),
|
apiClient
|
||||||
|
.get('/integrations/dawarich/atlas/suggestions', { params: { from, to }, timeout: DAWARICH_ATLAS_TIMEOUT_MS })
|
||||||
|
.then(r => r.data),
|
||||||
acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> =>
|
acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> =>
|
||||||
apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
|
apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
|
||||||
|
|
||||||
@@ -69,11 +93,11 @@ export const dawarichApi = {
|
|||||||
// the wrong day of the trip.
|
// the wrong day of the trip.
|
||||||
tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise<DawarichTrack> =>
|
tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||||
apiClient
|
apiClient
|
||||||
.get(`/integrations/dawarich/trips/${tripId}/track`, { params: { ...params, offset: localOffsetMinutes() }, signal })
|
.get(`/integrations/dawarich/trips/${tripId}/track`, { ...upstream, params: { ...params, offset: localOffsetMinutes() }, signal })
|
||||||
.then(r => r.data),
|
.then(r => r.data),
|
||||||
windowTrack: (from: string, to: string, signal?: AbortSignal): Promise<DawarichTrack> =>
|
windowTrack: (from: string, to: string, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||||
apiClient
|
apiClient
|
||||||
.get('/integrations/dawarich/track', { params: { from, to, offset: localOffsetMinutes() }, signal })
|
.get('/integrations/dawarich/track', { ...upstream, params: { from, to, offset: localOffsetMinutes() }, signal })
|
||||||
.then(r => r.data),
|
.then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,15 +26,18 @@ import {
|
|||||||
export default function DawarichTrailLayer({
|
export default function DawarichTrailLayer({
|
||||||
track,
|
track,
|
||||||
selectedDate,
|
selectedDate,
|
||||||
|
hiddenDates,
|
||||||
casingPane,
|
casingPane,
|
||||||
}: {
|
}: {
|
||||||
track: DawarichTrack | null
|
track: DawarichTrack | null
|
||||||
/** When set, only that local day is drawn. */
|
/** When set, only that local day is drawn. */
|
||||||
selectedDate?: string | null
|
selectedDate?: string | null
|
||||||
|
/** Local dates whose day is folded away in the day plan. */
|
||||||
|
hiddenDates?: ReadonlySet<string> | null
|
||||||
/** The `trek-track-casing` pane, when the renderer supports panes. */
|
/** The `trek-track-casing` pane, when the renderer supports panes. */
|
||||||
casingPane?: string
|
casingPane?: string
|
||||||
}) {
|
}) {
|
||||||
const segments = useMemo(() => trailSegments(track, selectedDate), [track, selectedDate])
|
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates), [track, selectedDate, hiddenDates])
|
||||||
if (segments.length === 0) return null
|
if (segments.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* FE-DAWARICH-TRAILUI-001 to FE-DAWARICH-TRAILUI-007: the recorded-route
|
* FE-DAWARICH-TRAILUI-001 to FE-DAWARICH-TRAILUI-009: the recorded-route
|
||||||
* toggle that sits with the compass and the layer switcher on the trip map.
|
* toggle that sits with the compass and the layer switcher on the trip map.
|
||||||
*
|
*
|
||||||
* The pill is small but it carries the only explanation the user ever gets for
|
* The pill is small but it carries the only explanation the user ever gets for
|
||||||
@@ -124,4 +124,26 @@ describe('DawarichTrailPill', () => {
|
|||||||
// button to find out why the map is empty.
|
// button to find out why the map is empty.
|
||||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Nothing was recorded on these dates')
|
expect(screen.getByRole('tooltip')).toHaveTextContent('Nothing was recorded on these dates')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-DAWARICH-TRAILUI-008: shows a spinning ring and a busy state while the route loads', () => {
|
||||||
|
pill({ active: true, status: 'loading' })
|
||||||
|
|
||||||
|
// A first fetch from somebody's own server can take a while. With nothing on
|
||||||
|
// the button it read as "done, no line", and the tester reloaded the page.
|
||||||
|
expect(screen.getByTestId('dawarich-trail-loading')).toBeInTheDocument()
|
||||||
|
expect(button()).toHaveAttribute('aria-busy', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DAWARICH-TRAILUI-009: drops the ring once there is an answer, and never shows it while the layer is off', () => {
|
||||||
|
const view = pill({ active: true, status: 'loading' })
|
||||||
|
|
||||||
|
view.rerender(<DawarichTrailPill active status="ready" onToggle={vi.fn()} />)
|
||||||
|
expect(screen.queryByTestId('dawarich-trail-loading')).not.toBeInTheDocument()
|
||||||
|
expect(button()).toHaveAttribute('aria-busy', 'false')
|
||||||
|
|
||||||
|
// Switched off mid-load: the hook still reports its last status, the button
|
||||||
|
// must not keep spinning for a layer nobody asked for any more.
|
||||||
|
view.rerender(<DawarichTrailPill active={false} status="loading" onToggle={vi.fn()} />)
|
||||||
|
expect(screen.queryByTestId('dawarich-trail-loading')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,10 +40,15 @@ export function DawarichTrailPill({
|
|||||||
// A problem worth seeing without reading the tooltip: the layer is on and
|
// A problem worth seeing without reading the tooltip: the layer is on and
|
||||||
// there is nothing to show.
|
// there is nothing to show.
|
||||||
const muted = active && (status === 'empty' || status === 'offline' || status === 'unavailable')
|
const muted = active && (status === 'empty' || status === 'offline' || status === 'unavailable')
|
||||||
|
// Loading has to be visible too. A first fetch from somebody's own server can
|
||||||
|
// take a while, and with nothing on the button it looked exactly like "done,
|
||||||
|
// but no line", so testers reloaded the page to find out.
|
||||||
|
const loading = active && status === 'loading'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
position: 'relative',
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
padding: 4,
|
padding: 4,
|
||||||
@@ -64,6 +69,7 @@ export function DawarichTrailPill({
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
|
aria-busy={loading}
|
||||||
data-testid="dawarich-trail-pill"
|
data-testid="dawarich-trail-pill"
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
@@ -88,6 +94,22 @@ export function DawarichTrailPill({
|
|||||||
<DawarichIcon fill />
|
<DawarichIcon fill />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{loading && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="dawarich-trail-loading"
|
||||||
|
className="motion-safe:animate-spin"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: '2px solid transparent',
|
||||||
|
borderTopColor: 'var(--accent)',
|
||||||
|
borderRightColor: 'var(--accent)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -676,6 +676,7 @@ export const MapView = memo(function MapView({
|
|||||||
hazards,
|
hazards,
|
||||||
dawarichTrack = null,
|
dawarichTrack = null,
|
||||||
dawarichSelectedDate = null,
|
dawarichSelectedDate = null,
|
||||||
|
dawarichHiddenDates = null,
|
||||||
accessLines = [],
|
accessLines = [],
|
||||||
onPoiDropOnRoute,
|
onPoiDropOnRoute,
|
||||||
onRouteClick,
|
onRouteClick,
|
||||||
@@ -1201,6 +1202,7 @@ export const MapView = memo(function MapView({
|
|||||||
<DawarichTrailLayer
|
<DawarichTrailLayer
|
||||||
track={dawarichTrack}
|
track={dawarichTrack}
|
||||||
selectedDate={dawarichSelectedDate}
|
selectedDate={dawarichSelectedDate}
|
||||||
|
hiddenDates={dawarichHiddenDates}
|
||||||
casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined}
|
casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined}
|
||||||
/>
|
/>
|
||||||
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
|
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
|
||||||
|
|||||||
@@ -197,6 +197,8 @@ interface Props {
|
|||||||
dawarichTrack?: DawarichTrack | null
|
dawarichTrack?: DawarichTrack | null
|
||||||
/** Draw only this local day of the recording. */
|
/** Draw only this local day of the recording. */
|
||||||
dawarichSelectedDate?: string | null
|
dawarichSelectedDate?: string | null
|
||||||
|
/** Local dates whose day is collapsed in the day plan; their recording is not drawn. */
|
||||||
|
dawarichHiddenDates?: ReadonlySet<string> | null
|
||||||
/** Via points to draw as draggable handles, keyed by day (#1797). */
|
/** Via points to draw as draggable handles, keyed by day (#1797). */
|
||||||
roadtripVias?: Record<number, RoadtripVia[]>
|
roadtripVias?: Record<number, RoadtripVia[]>
|
||||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||||
@@ -646,6 +648,7 @@ export function MapViewGL({
|
|||||||
hazards,
|
hazards,
|
||||||
dawarichTrack = null,
|
dawarichTrack = null,
|
||||||
dawarichSelectedDate = null,
|
dawarichSelectedDate = null,
|
||||||
|
dawarichHiddenDates = null,
|
||||||
dayOrderMap = NO_DAY_ORDER,
|
dayOrderMap = NO_DAY_ORDER,
|
||||||
leftWidth = 0,
|
leftWidth = 0,
|
||||||
rightWidth = 0,
|
rightWidth = 0,
|
||||||
@@ -724,7 +727,7 @@ export function MapViewGL({
|
|||||||
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
|
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
|
||||||
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
||||||
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
|
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
|
||||||
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing')
|
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||||
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* dawarichTrail unit tests: FE-DAWARICH-TRAIL-001 to FE-DAWARICH-TRAIL-008.
|
* dawarichTrail unit tests: FE-DAWARICH-TRAIL-001 to FE-DAWARICH-TRAIL-008, and
|
||||||
|
* FE-DAWARICH-TRAIL-030 to FE-DAWARICH-TRAIL-032 for the days folded away.
|
||||||
*
|
*
|
||||||
* This module is the whole of what the two map renderers agree on about the
|
* This module is the whole of what the two map renderers agree on about the
|
||||||
* recorded route: Leaflet turns `trailSegments` into `<Polyline>` elements, the
|
* recorded route: Leaflet turns `trailSegments` into `<Polyline>` elements, the
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
DAWARICH_DAY_COLORS,
|
DAWARICH_DAY_COLORS,
|
||||||
DAWARICH_TRAIL_CASING,
|
DAWARICH_TRAIL_CASING,
|
||||||
DAWARICH_TRAIL_COLOR,
|
DAWARICH_TRAIL_COLOR,
|
||||||
|
collapsedDayDates,
|
||||||
dayColor,
|
dayColor,
|
||||||
trailDistanceKm,
|
trailDistanceKm,
|
||||||
trailGeoJson,
|
trailGeoJson,
|
||||||
@@ -227,3 +229,40 @@ describe('trailDistanceKm', () => {
|
|||||||
expect(trailDistanceKm([])).toBeNull()
|
expect(trailDistanceKm([])).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('the days folded away in the day plan', () => {
|
||||||
|
it('FE-DAWARICH-TRAIL-030: a collapsed day takes its route off the map and leaves the colours of the rest alone', () => {
|
||||||
|
const all = trailSegments(THREE_DAYS)
|
||||||
|
const visible = trailSegments(THREE_DAYS, null, new Set(['2026-05-02']))
|
||||||
|
|
||||||
|
expect(visible.map(segment => segment.date)).toEqual(['2026-05-01', '2026-05-01', '2026-05-03'])
|
||||||
|
// Folding day two must not shift day three onto day two's colour.
|
||||||
|
expect(visible.find(segment => segment.date === '2026-05-03')?.color)
|
||||||
|
.toBe(all.find(segment => segment.date === '2026-05-03')?.color)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DAWARICH-TRAIL-031: a picked day that is also folded away draws nothing', () => {
|
||||||
|
expect(trailSegments(THREE_DAYS, '2026-05-01', new Set(['2026-05-01']))).toEqual([])
|
||||||
|
expect(trailSegments(THREE_DAYS, '2026-05-01', new Set(['2026-05-03']))).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DAWARICH-TRAIL-032: follows both folds the places follow, and nothing before the day plan has spoken', () => {
|
||||||
|
const days = [
|
||||||
|
{ id: 1, date: '2026-05-03' },
|
||||||
|
{ id: 2, date: '2026-05-01' },
|
||||||
|
{ id: 3, date: null },
|
||||||
|
{ id: 4, date: '2026-05-02' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Null is "the day plan has not reported yet": the places show, so the
|
||||||
|
// route shows too.
|
||||||
|
expect(collapsedDayDates(days, null)).toEqual([])
|
||||||
|
|
||||||
|
// Sorted, deduplicated, and a day without a date matches no recording.
|
||||||
|
expect(collapsedDayDates(days, new Set([4]))).toEqual(['2026-05-01', '2026-05-03'])
|
||||||
|
|
||||||
|
// Road trip mode folds days in its own sidebar, on top of the day plan.
|
||||||
|
expect(collapsedDayDates(days, null, new Set([4]))).toEqual(['2026-05-02'])
|
||||||
|
expect(collapsedDayDates(days, new Set([1, 2, 3, 4]), new Set([1, 3]))).toEqual(['2026-05-03'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -64,20 +64,21 @@ export interface DawarichTrailSegment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flatten a fetched track into drawable segments, optionally limited to one day.
|
* Flatten a fetched track into drawable segments, optionally limited to one day
|
||||||
|
* and without the days folded away in the day plan.
|
||||||
*
|
*
|
||||||
* The day filter is what the timeline scrubber drives: selecting a day on the
|
* Both filters work on data that is already in memory: selecting or collapsing a
|
||||||
* planner should dim the rest of the trip rather than reload it, so the filter
|
* day changes what is drawn, it does not reload the recording.
|
||||||
* happens here over data that is already in memory.
|
|
||||||
*/
|
*/
|
||||||
export function trailSegments(
|
export function trailSegments(
|
||||||
track: DawarichTrack | null,
|
track: DawarichTrack | null,
|
||||||
onlyDate?: string | null,
|
onlyDate?: string | null,
|
||||||
|
hiddenDates?: ReadonlySet<string> | null,
|
||||||
): DawarichTrailSegment[] {
|
): DawarichTrailSegment[] {
|
||||||
if (!track) return []
|
if (!track) return []
|
||||||
const days: DawarichTrackDay[] = onlyDate
|
const days: DawarichTrackDay[] = track.days.filter(
|
||||||
? track.days.filter(day => day.date === onlyDate)
|
day => (!onlyDate || day.date === onlyDate) && !hiddenDates?.has(day.date),
|
||||||
: track.days
|
)
|
||||||
|
|
||||||
const out: DawarichTrailSegment[] = []
|
const out: DawarichTrailSegment[] = []
|
||||||
for (const day of days) {
|
for (const day of days) {
|
||||||
@@ -108,6 +109,30 @@ export function trailSegments(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dates whose recording stays off the map because their day is collapsed.
|
||||||
|
*
|
||||||
|
* The same rule the planner already applies to places: a collapsed day takes
|
||||||
|
* its markers off the map, so it takes its route with it. Two folds count, as
|
||||||
|
* they do for places: the day plan's (`expandedDayIds`, null until the day plan
|
||||||
|
* has reported its state, and then nothing is hidden by it) and, in road trip
|
||||||
|
* mode, the road trip sidebar's own. A day without a date has nothing to match a
|
||||||
|
* recording against. Sorted, so the caller can key a memo on the joined list.
|
||||||
|
*/
|
||||||
|
export function collapsedDayDates(
|
||||||
|
days: ReadonlyArray<{ id: number; date?: string | null }>,
|
||||||
|
expandedDayIds: ReadonlySet<number> | null,
|
||||||
|
collapsedDayIds?: ReadonlySet<number> | null,
|
||||||
|
): string[] {
|
||||||
|
const dates = new Set<string>()
|
||||||
|
for (const day of days) {
|
||||||
|
if (!day.date) continue
|
||||||
|
const foldedInPlan = !!expandedDayIds && !expandedDayIds.has(day.id)
|
||||||
|
if (foldedInPlan || collapsedDayIds?.has(day.id)) dates.add(day.date)
|
||||||
|
}
|
||||||
|
return [...dates].sort()
|
||||||
|
}
|
||||||
|
|
||||||
/** The overlay as a GeoJSON FeatureCollection, for the GL renderer. */
|
/** The overlay as a GeoJSON FeatureCollection, for the GL renderer. */
|
||||||
export function trailGeoJson(segments: DawarichTrailSegment[]): {
|
export function trailGeoJson(segments: DawarichTrailSegment[]): {
|
||||||
type: 'FeatureCollection'
|
type: 'FeatureCollection'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* FE-DAWARICH-TRAILUI-020 to FE-DAWARICH-TRAILUI-029: the recorded-route
|
* FE-DAWARICH-TRAILUI-020 to FE-DAWARICH-TRAILUI-032: the recorded-route
|
||||||
* overlay on the GL renderer, the imperative twin of `DawarichTrailLayer`.
|
* overlay on the GL renderer, the imperative twin of `DawarichTrailLayer`.
|
||||||
*
|
*
|
||||||
* A declarative overlay that gets its ordering wrong looks wrong. An imperative
|
* A declarative overlay that gets its ordering wrong looks wrong. An imperative
|
||||||
@@ -28,6 +28,10 @@
|
|||||||
* sources. Both paths are pinned, because which one a refresh takes is the
|
* sources. Both paths are pinned, because which one a refresh takes is the
|
||||||
* difference between a line that blinks every two minutes and one that does
|
* difference between a line that blinks every two minutes and one that does
|
||||||
* not.
|
* not.
|
||||||
|
* - **the wait for `idle`.** `isStyleLoaded()` is false while any tile is
|
||||||
|
* still in flight, not only before the first style. A draw that gave up
|
||||||
|
* there and only listened for `style.load` left the line off the map until
|
||||||
|
* the page was reloaded, which is what a tester on dev1 ran into.
|
||||||
* - **one `style.load` subscription at a time.** The effect re-runs on every
|
* - **one `style.load` subscription at a time.** The effect re-runs on every
|
||||||
* refresh, and a cleanup that forgot to unsubscribe would add one silent
|
* refresh, and a cleanup that forgot to unsubscribe would add one silent
|
||||||
* redraw per refresh for the life of the map.
|
* redraw per refresh for the life of the map.
|
||||||
@@ -61,6 +65,7 @@ function fakeGlMap({ styleLoaded = true, styleLayers = [] as string[] } = {}) {
|
|||||||
const layers = new Map<string, { spec: { id: string; paint?: Record<string, unknown> }; before: string | undefined }>()
|
const layers = new Map<string, { spec: { id: string; paint?: Record<string, unknown> }; before: string | undefined }>()
|
||||||
for (const id of styleLayers) layers.set(id, { spec: { id }, before: undefined })
|
for (const id of styleLayers) layers.set(id, { spec: { id }, before: undefined })
|
||||||
const listeners = new Map<string, Set<() => void>>()
|
const listeners = new Map<string, Set<() => void>>()
|
||||||
|
const onceListeners = new Map<string, Set<() => void>>()
|
||||||
|
|
||||||
const state = { styleLoaded }
|
const state = { styleLoaded }
|
||||||
const map = {
|
const map = {
|
||||||
@@ -84,7 +89,15 @@ function fakeGlMap({ styleLoaded = true, styleLayers = [] as string[] } = {}) {
|
|||||||
set.add(fn)
|
set.add(fn)
|
||||||
listeners.set(type, set)
|
listeners.set(type, set)
|
||||||
}),
|
}),
|
||||||
off: vi.fn((type: string, fn: () => void) => { listeners.get(type)?.delete(fn) }),
|
once: vi.fn((type: string, fn: () => void) => {
|
||||||
|
const set = onceListeners.get(type) ?? new Set<() => void>()
|
||||||
|
set.add(fn)
|
||||||
|
onceListeners.set(type, set)
|
||||||
|
}),
|
||||||
|
off: vi.fn((type: string, fn: () => void) => {
|
||||||
|
listeners.get(type)?.delete(fn)
|
||||||
|
onceListeners.get(type)?.delete(fn)
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -95,6 +108,13 @@ function fakeGlMap({ styleLoaded = true, styleLayers = [] as string[] } = {}) {
|
|||||||
state,
|
state,
|
||||||
styleLoadListeners: () => listeners.get('style.load')?.size ?? 0,
|
styleLoadListeners: () => listeners.get('style.load')?.size ?? 0,
|
||||||
emitStyleLoad: () => { listeners.get('style.load')?.forEach(fn => fn()) },
|
emitStyleLoad: () => { listeners.get('style.load')?.forEach(fn => fn()) },
|
||||||
|
idleListeners: () => onceListeners.get('idle')?.size ?? 0,
|
||||||
|
/** A `once` listener is gone before it runs, the way GL's Evented does it. */
|
||||||
|
emitIdle: () => {
|
||||||
|
const pending = [...(onceListeners.get('idle') ?? [])]
|
||||||
|
onceListeners.delete('idle')
|
||||||
|
pending.forEach(fn => fn())
|
||||||
|
},
|
||||||
/** What a style rebuild does to the map before `style.load` fires. */
|
/** What a style rebuild does to the map before `style.load` fires. */
|
||||||
dropStyle: () => { sources.clear(); layers.clear() },
|
dropStyle: () => { sources.clear(); layers.clear() },
|
||||||
drawn: () => sources.get(SOURCE)?.data as
|
drawn: () => sources.get(SOURCE)?.data as
|
||||||
@@ -109,10 +129,11 @@ interface Props {
|
|||||||
track?: DawarichTrack | null
|
track?: DawarichTrack | null
|
||||||
date?: string | null
|
date?: string | null
|
||||||
before?: string
|
before?: string
|
||||||
|
hidden?: ReadonlySet<string> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const mount = (props: Props) =>
|
const mount = (props: Props) =>
|
||||||
renderHook((p: Props) => useDawarichTrailGL(p.map, p.ready, p.track, p.date, p.before), {
|
renderHook((p: Props) => useDawarichTrailGL(p.map, p.ready, p.track, p.date, p.before, p.hidden), {
|
||||||
initialProps: props,
|
initialProps: props,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -314,4 +335,53 @@ describe('useDawarichTrailGL', () => {
|
|||||||
// change per refresh, for as long as the map lives.
|
// change per refresh, for as long as the map lives.
|
||||||
expect(gl.styleLoadListeners()).toBe(1)
|
expect(gl.styleLoadListeners()).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-DAWARICH-TRAILUI-030: a route that lands while tiles are still loading is drawn once they have arrived', () => {
|
||||||
|
// The tester's case: the overlay switched on, the map still filling in,
|
||||||
|
// and the line only appearing after a reload.
|
||||||
|
const gl = fakeGlMap({ styleLoaded: false })
|
||||||
|
mount({ map: gl.map, ready: true, track: track([DAY_ONE]) })
|
||||||
|
|
||||||
|
expect(gl.spies.addSource).not.toHaveBeenCalled()
|
||||||
|
expect(gl.idleListeners()).toBe(1)
|
||||||
|
|
||||||
|
gl.state.styleLoaded = true
|
||||||
|
gl.emitIdle()
|
||||||
|
|
||||||
|
expect(gl.spies.addSource).toHaveBeenCalledTimes(1)
|
||||||
|
expect(gl.spies.addLayer).toHaveBeenCalledTimes(2)
|
||||||
|
expect(gl.idleListeners()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DAWARICH-TRAILUI-031: waits for idle once however often it is asked, and keeps waiting while the map is busy', () => {
|
||||||
|
const gl = fakeGlMap({ styleLoaded: false })
|
||||||
|
const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE]) })
|
||||||
|
|
||||||
|
// A style.load that fires while tiles are still in flight is a second draw
|
||||||
|
// attempt; it must not stack a second wait on the first.
|
||||||
|
gl.emitStyleLoad()
|
||||||
|
expect(gl.idleListeners()).toBe(1)
|
||||||
|
|
||||||
|
// An idle that arrives before the style is ready (a transition finished,
|
||||||
|
// another source started) is not the end of the wait.
|
||||||
|
gl.emitIdle()
|
||||||
|
expect(gl.spies.addSource).not.toHaveBeenCalled()
|
||||||
|
expect(gl.idleListeners()).toBe(1)
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
expect(gl.idleListeners()).toBe(0)
|
||||||
|
expect(gl.spies.off).toHaveBeenCalledWith('idle', expect.any(Function))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-DAWARICH-TRAILUI-032: leaves out the days folded away in the day plan without repainting the others', () => {
|
||||||
|
const gl = fakeGlMap()
|
||||||
|
const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]) })
|
||||||
|
const secondColour = gl.drawn()?.features[1]?.properties.color
|
||||||
|
|
||||||
|
view.rerender({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]), hidden: new Set([DAY_ONE.date]) })
|
||||||
|
|
||||||
|
expect(gl.drawn()?.features).toHaveLength(1)
|
||||||
|
expect(gl.drawn()?.features[0]?.properties.date).toBe(DAY_TWO.date)
|
||||||
|
expect(gl.drawn()?.features[0]?.properties.color).toBe(secondColour)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,14 +28,29 @@ export function useDawarichTrailGL(
|
|||||||
selectedDate?: string | null,
|
selectedDate?: string | null,
|
||||||
/** Insert beneath this layer, so the planned route stays on top. */
|
/** Insert beneath this layer, so the planned route stays on top. */
|
||||||
beforeId?: string,
|
beforeId?: string,
|
||||||
|
/** Local dates whose day is folded away in the day plan. */
|
||||||
|
hiddenDates?: ReadonlySet<string> | null,
|
||||||
): void {
|
): void {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !ready) return
|
if (!map || !ready) return
|
||||||
|
|
||||||
const data = trailGeoJson(trailSegments(track ?? null, selectedDate))
|
const data = trailGeoJson(trailSegments(track ?? null, selectedDate, hiddenDates))
|
||||||
|
|
||||||
|
// `isStyleLoaded()` does not mean "the style has loaded". It is false while
|
||||||
|
// any source still has tiles in flight, which is the usual state right after
|
||||||
|
// someone switches the overlay on and the map is still filling in. Giving up
|
||||||
|
// there left the line undrawn until something rebuilt the style, which in
|
||||||
|
// practice meant reloading the page. `idle` is the map saying everything it
|
||||||
|
// was fetching has arrived, so the draw waits for it once and tries again.
|
||||||
|
let waiting = false
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
if (!map.isStyleLoaded()) return
|
if (!map.isStyleLoaded()) {
|
||||||
|
if (!waiting) {
|
||||||
|
waiting = true
|
||||||
|
map.once('idle', retry)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
const existing = map.getSource(SOURCE) as GeoJSONSource | undefined
|
const existing = map.getSource(SOURCE) as GeoJSONSource | undefined
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// setData rather than remove-and-add: a rebuilt source flickers, and a
|
// setData rather than remove-and-add: a rebuilt source flickers, and a
|
||||||
@@ -77,14 +92,20 @@ export function useDawarichTrailGL(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const retry = () => {
|
||||||
|
waiting = false
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
draw()
|
draw()
|
||||||
map.on('style.load', draw)
|
map.on('style.load', draw)
|
||||||
return () => {
|
return () => {
|
||||||
map.off('style.load', draw)
|
map.off('style.load', draw)
|
||||||
|
map.off('idle', retry)
|
||||||
for (const layer of [LINE_LAYER, CASING_LAYER]) {
|
for (const layer of [LINE_LAYER, CASING_LAYER]) {
|
||||||
if (map.getLayer(layer)) map.removeLayer(layer)
|
if (map.getLayer(layer)) map.removeLayer(layer)
|
||||||
}
|
}
|
||||||
if (map.getSource(SOURCE)) map.removeSource(SOURCE)
|
if (map.getSource(SOURCE)) map.removeSource(SOURCE)
|
||||||
}
|
}
|
||||||
}, [map, ready, track, selectedDate, beforeId])
|
}, [map, ready, track, selectedDate, beforeId, hiddenDates])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* FE-ROADTRIP-HAZARDGL-001 to FE-ROADTRIP-HAZARDGL-003: when the hazard overlay
|
||||||
|
* on the GL renderer actually gets drawn.
|
||||||
|
*
|
||||||
|
* It shares a failure with the recorded Dawarich route: `isStyleLoaded()` is
|
||||||
|
* false while any source still has tiles in flight, not only before the first
|
||||||
|
* style. A draw that gave up there and waited for `style.load` alone stayed off
|
||||||
|
* the map until the style was rebuilt, which in practice meant a reload. These
|
||||||
|
* cases pin the wait for `idle` that replaced it, and that the wait does not
|
||||||
|
* outlive the component.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import type { Map as MapboxMap, Popup } from 'mapbox-gl'
|
||||||
|
import type { RoadtripHazard } from '@trek/shared'
|
||||||
|
import { useHazardLayerGL } from './useHazardLayerGL'
|
||||||
|
|
||||||
|
vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||||
|
|
||||||
|
const SOURCE = 'roadtrip-hazards'
|
||||||
|
|
||||||
|
const HAZARD: RoadtripHazard = {
|
||||||
|
id: 'dwd-1',
|
||||||
|
source: 'DWD',
|
||||||
|
title: 'Sturmboeen',
|
||||||
|
description: 'Boeen bis 85 km/h',
|
||||||
|
updatedAt: '2026-09-14T10:00:00.000Z',
|
||||||
|
validUntil: null,
|
||||||
|
url: 'https://www.dwd.de/warnungen',
|
||||||
|
geometry: { type: 'Point', coordinates: [13.4, 52.5] },
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeGlMap(styleLoaded: boolean) {
|
||||||
|
const sources = new Set<string>()
|
||||||
|
const layers = new Set<string>()
|
||||||
|
const once = new Map<string, Set<() => void>>()
|
||||||
|
const state = { styleLoaded }
|
||||||
|
const map = {
|
||||||
|
isStyleLoaded: vi.fn(() => state.styleLoaded),
|
||||||
|
getSource: vi.fn((id: string) => (sources.has(id) ? {} : undefined)),
|
||||||
|
addSource: vi.fn((id: string) => { sources.add(id) }),
|
||||||
|
removeSource: vi.fn((id: string) => { sources.delete(id) }),
|
||||||
|
getLayer: vi.fn((id: string) => (layers.has(id) ? {} : undefined)),
|
||||||
|
addLayer: vi.fn((spec: { id: string }) => { layers.add(spec.id) }),
|
||||||
|
removeLayer: vi.fn((id: string) => { layers.delete(id) }),
|
||||||
|
on: vi.fn(),
|
||||||
|
once: vi.fn((type: string, fn: () => void) => {
|
||||||
|
const set = once.get(type) ?? new Set<() => void>()
|
||||||
|
set.add(fn)
|
||||||
|
once.set(type, set)
|
||||||
|
}),
|
||||||
|
off: vi.fn((type: string, fn: () => void) => { once.get(type)?.delete(fn) }),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
map: map as unknown as MapboxMap,
|
||||||
|
spies: map,
|
||||||
|
state,
|
||||||
|
sources,
|
||||||
|
idleListeners: () => once.get('idle')?.size ?? 0,
|
||||||
|
emitIdle: () => {
|
||||||
|
const pending = [...(once.get('idle') ?? [])]
|
||||||
|
once.delete('idle')
|
||||||
|
pending.forEach(fn => fn())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const popup = () => ({}) as Popup
|
||||||
|
|
||||||
|
describe('useHazardLayerGL', () => {
|
||||||
|
it('FE-ROADTRIP-HAZARDGL-001: draws straight away into a style that is ready', () => {
|
||||||
|
const gl = fakeGlMap(true)
|
||||||
|
renderHook(() => useHazardLayerGL(gl.map, true, [HAZARD], popup))
|
||||||
|
|
||||||
|
expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' }))
|
||||||
|
expect(gl.spies.addLayer).toHaveBeenCalledTimes(3)
|
||||||
|
expect(gl.idleListeners()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-HAZARDGL-002: hazards that arrive while tiles are loading are drawn once the map is idle', () => {
|
||||||
|
const gl = fakeGlMap(false)
|
||||||
|
renderHook(() => useHazardLayerGL(gl.map, true, [HAZARD], popup))
|
||||||
|
expect(gl.spies.addSource).not.toHaveBeenCalled()
|
||||||
|
expect(gl.idleListeners()).toBe(1)
|
||||||
|
|
||||||
|
// Still busy at the first idle: wait again, and only once.
|
||||||
|
gl.emitIdle()
|
||||||
|
expect(gl.spies.addSource).not.toHaveBeenCalled()
|
||||||
|
expect(gl.idleListeners()).toBe(1)
|
||||||
|
|
||||||
|
gl.state.styleLoaded = true
|
||||||
|
gl.emitIdle()
|
||||||
|
expect(gl.spies.addSource).toHaveBeenCalledTimes(1)
|
||||||
|
expect(gl.idleListeners()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-ROADTRIP-HAZARDGL-003: an unmount while waiting takes the wait with it', () => {
|
||||||
|
const gl = fakeGlMap(false)
|
||||||
|
const view = renderHook(() => useHazardLayerGL(gl.map, true, [HAZARD], popup))
|
||||||
|
|
||||||
|
view.unmount()
|
||||||
|
|
||||||
|
expect(gl.idleListeners()).toBe(0)
|
||||||
|
expect(gl.spies.off).toHaveBeenCalledWith('idle', expect.any(Function))
|
||||||
|
expect(gl.sources.size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,8 +11,19 @@ export function useHazardLayerGL(map: Map | null, ready: boolean, hazards: Roadt
|
|||||||
const source = 'roadtrip-hazards'
|
const source = 'roadtrip-hazards'
|
||||||
const layers = [`${source}-fill`, `${source}-line`, `${source}-point`]
|
const layers = [`${source}-fill`, `${source}-line`, `${source}-point`]
|
||||||
let detail: Popup | undefined
|
let detail: Popup | undefined
|
||||||
|
// Waits for `idle` rather than giving up while tiles are still loading, for
|
||||||
|
// the reason spelled out in useDawarichTrailGL: `isStyleLoaded()` is false
|
||||||
|
// for as long as any source has tiles in flight.
|
||||||
|
let waiting = false
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
if (!map.isStyleLoaded() || map.getSource(source)) return
|
if (map.getSource(source)) return
|
||||||
|
if (!map.isStyleLoaded()) {
|
||||||
|
if (!waiting) {
|
||||||
|
waiting = true
|
||||||
|
map.once('idle', retry)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
const color = getComputedStyle(document.documentElement).getPropertyValue('--warning').trim()
|
const color = getComputedStyle(document.documentElement).getPropertyValue('--warning').trim()
|
||||||
map.addSource(source, { type: 'geojson', data: { type: 'FeatureCollection', features: hazards.map(hazardFeature) } })
|
map.addSource(source, { type: 'geojson', data: { type: 'FeatureCollection', features: hazards.map(hazardFeature) } })
|
||||||
map.addLayer({ id: layers[0], type: 'fill', source, filter: ['!=', ['geometry-type'], 'Point'], paint: { 'fill-color': color, 'fill-opacity': 0.16 } })
|
map.addLayer({ id: layers[0], type: 'fill', source, filter: ['!=', ['geometry-type'], 'Point'], paint: { 'fill-color': color, 'fill-opacity': 0.16 } })
|
||||||
@@ -27,12 +38,17 @@ export function useHazardLayerGL(map: Map | null, ready: boolean, hazards: Roadt
|
|||||||
detail?.remove()
|
detail?.remove()
|
||||||
detail = popup().setLngLat(event.lngLat).setDOMContent(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point'))).addTo(map)
|
detail = popup().setLngLat(event.lngLat).setDOMContent(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point'))).addTo(map)
|
||||||
}
|
}
|
||||||
|
const retry = () => {
|
||||||
|
waiting = false
|
||||||
|
draw()
|
||||||
|
}
|
||||||
draw()
|
draw()
|
||||||
map.on('style.load', draw)
|
map.on('style.load', draw)
|
||||||
map.on('click', click)
|
map.on('click', click)
|
||||||
return () => {
|
return () => {
|
||||||
detail?.remove()
|
detail?.remove()
|
||||||
map.off('style.load', draw)
|
map.off('style.load', draw)
|
||||||
|
map.off('idle', retry)
|
||||||
map.off('click', click)
|
map.off('click', click)
|
||||||
for (const layer of layers) if (map.getLayer(layer)) map.removeLayer(layer)
|
for (const layer of layers) if (map.getLayer(layer)) map.removeLayer(layer)
|
||||||
if (map.getSource(source)) map.removeSource(source)
|
if (map.getSource(source)) map.removeSource(source)
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
|||||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||||
overviewActive, tripOverview, toggleOverview, overviewShown,
|
overviewActive, tripOverview, toggleOverview, overviewShown,
|
||||||
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail,
|
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates,
|
||||||
followTrack, roadtripViaCounts,
|
followTrack, roadtripViaCounts,
|
||||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||||
@@ -415,6 +415,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
|||||||
<MapView
|
<MapView
|
||||||
tripId={tripId}
|
tripId={tripId}
|
||||||
dawarichTrack={dawarichTrail.track}
|
dawarichTrack={dawarichTrail.track}
|
||||||
|
dawarichHiddenDates={dawarichHiddenDates}
|
||||||
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
|
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
|
||||||
dayPlaces={dayPlaces}
|
dayPlaces={dayPlaces}
|
||||||
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
|
route={roadtripActive ? roadtripMapLines : overviewActive ? tripOverview.lines : route}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
|
|||||||
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
|
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
|
||||||
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
||||||
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
|
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
|
||||||
|
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
|
||||||
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
||||||
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||||
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
|
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
|
||||||
@@ -955,6 +956,19 @@ export function useTripPlanner() {
|
|||||||
return plannedPlaces.filter(p => !hidden.has(p.id))
|
return plannedPlaces.filter(p => !hidden.has(p.id))
|
||||||
}, [mapPlaces, assignments, roadtripRoutes.days, collapsedRoadtripDays])
|
}, [mapPlaces, assignments, roadtripRoutes.days, collapsedRoadtripDays])
|
||||||
|
|
||||||
|
// The recorded route follows the same folds as the places above, so a
|
||||||
|
// collapsed day does not leave its line behind on the map. Keyed on the joined
|
||||||
|
// dates rather than on the Set, because `days` changes identity on every store
|
||||||
|
// update and the GL overlay rebuilds its source whenever this reference moves.
|
||||||
|
const dawarichHiddenKey = useMemo(
|
||||||
|
() => collapsedDayDates(days, expandedDayIds, roadtripActive ? collapsedRoadtripDays : null).join('|'),
|
||||||
|
[days, expandedDayIds, roadtripActive, collapsedRoadtripDays],
|
||||||
|
)
|
||||||
|
const dawarichHiddenDates = useMemo(
|
||||||
|
() => (dawarichHiddenKey ? new Set(dawarichHiddenKey.split('|')) : null),
|
||||||
|
[dawarichHiddenKey],
|
||||||
|
)
|
||||||
|
|
||||||
const roadtripLineColors = useMemo(
|
const roadtripLineColors = useMemo(
|
||||||
() => {
|
() => {
|
||||||
if (!roadtripSettings.roadtrip_day_colors) return undefined
|
if (!roadtripSettings.roadtrip_day_colors) return undefined
|
||||||
@@ -2390,7 +2404,7 @@ export function useTripPlanner() {
|
|||||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||||
overviewShown, toggleOverview, overviewActive, tripOverview,
|
overviewShown, toggleOverview, overviewActive, tripOverview,
|
||||||
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichEnabled: !!enabledAddons.dawarich,
|
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled: !!enabledAddons.dawarich,
|
||||||
followTrack, roadtripViaCounts,
|
followTrack, roadtripViaCounts,
|
||||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import type { DawarichErrorCode } from '@trek/shared';
|
import type { DawarichErrorCode } from '@trek/shared';
|
||||||
import { safeFetch } from '../../utils/ssrfGuard';
|
import { safeFetch } from '../../utils/ssrfGuard';
|
||||||
import { readCappedJson } from '../../utils/cappedFetch';
|
import { readCappedJson } from '../../utils/cappedFetch';
|
||||||
|
import { mergeVisitedCountries, splitWindow, VISITED_CITIES_WINDOW_DAYS } from './dawarich.helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin HTTP client for the Dawarich REST API (github.com/Freika/dawarich).
|
* Thin HTTP client for the Dawarich REST API (github.com/Freika/dawarich).
|
||||||
@@ -385,17 +386,31 @@ export class DawarichClient {
|
|||||||
return { points, truncated };
|
return { points, truncated };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Countries and cities the recordings put the user in, for the Atlas. */
|
/**
|
||||||
|
* Countries and cities the recordings put the user in, for the Atlas.
|
||||||
|
*
|
||||||
|
* Asked for a month at a time and folded back together: Dawarich computes this
|
||||||
|
* over every point in the window, so a year in one request outlasted the
|
||||||
|
* timeout for anyone who records densely (see VISITED_CITIES_WINDOW_DAYS).
|
||||||
|
* The windows go one after another rather than in parallel, because each one
|
||||||
|
* is a full computation on the user's own server.
|
||||||
|
*/
|
||||||
async listVisitedCities(
|
async listVisitedCities(
|
||||||
creds: DawarichCreds,
|
creds: DawarichCreds,
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date,
|
to: Date,
|
||||||
): Promise<DawarichVisitedCountry[]> {
|
): Promise<DawarichVisitedCountry[]> {
|
||||||
const { data } = await this.get<{ data?: DawarichVisitedCountry[] }>(creds, '/countries/visited_cities', {
|
const answers: DawarichVisitedCountry[][] = [];
|
||||||
start_at: unixSeconds(from),
|
for (const window of splitWindow(from, to, VISITED_CITIES_WINDOW_DAYS)) {
|
||||||
end_at: unixSeconds(to),
|
const { data } = await this.get<{ data?: DawarichVisitedCountry[] }>(creds, '/countries/visited_cities', {
|
||||||
});
|
start_at: unixSeconds(window.from),
|
||||||
return Array.isArray(data?.data) ? data.data : [];
|
end_at: unixSeconds(window.to),
|
||||||
|
});
|
||||||
|
answers.push(Array.isArray(data?.data) ? data.data : []);
|
||||||
|
}
|
||||||
|
// A single window is passed through untouched, so the probe and every short
|
||||||
|
// range see exactly what Dawarich sent.
|
||||||
|
return answers.length === 1 ? answers[0] : mergeVisitedCountries(answers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import type { DawarichTrackDay, DawarichTrackSegment } from '@trek/shared';
|
import type { DawarichTrackDay, DawarichTrackSegment } from '@trek/shared';
|
||||||
import type { DawarichSlimPoint, DawarichTrackFeature, DawarichVisitRaw } from './dawarich.client';
|
import type { DawarichSlimPoint, DawarichTrackFeature, DawarichVisitRaw, DawarichVisitedCountry } from './dawarich.client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure helpers for the Dawarich integration — no database, no HTTP, no
|
* Pure helpers for the Dawarich integration — no database, no HTTP, no
|
||||||
@@ -514,3 +514,88 @@ export function countPoints(days: DawarichTrackDay[]): number {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Visited cities ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much recording one `visited_cities` request may cover.
|
||||||
|
*
|
||||||
|
* Dawarich computes that endpoint on the spot: it loads every point in the
|
||||||
|
* window, sorts them in memory and walks them into stays, with nothing cached.
|
||||||
|
* A year in one request is fine for someone who records a point a minute and a
|
||||||
|
* certain timeout for someone who records one every few seconds, because the
|
||||||
|
* work grows with the points while the request budget stays fixed. A month is
|
||||||
|
* a twelfth of that work per request.
|
||||||
|
*/
|
||||||
|
export const VISITED_CITIES_WINDOW_DAYS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `[from, to]` cut into consecutive windows of at most `days`.
|
||||||
|
*
|
||||||
|
* Dawarich reads the bounds as an inclusive range (`start_at..end_at`), so each
|
||||||
|
* window after the first starts one second past the end of the one before. With
|
||||||
|
* touching bounds a point recorded on that exact second would be counted twice.
|
||||||
|
* Bounds are whole seconds for the same reason: that is what goes on the wire.
|
||||||
|
*/
|
||||||
|
export function splitWindow(from: Date, to: Date, days: number): Array<{ from: Date; to: Date }> {
|
||||||
|
const step = days * 86_400_000;
|
||||||
|
const end = Math.floor(to.getTime() / 1000) * 1000;
|
||||||
|
const windows: Array<{ from: Date; to: Date }> = [];
|
||||||
|
let start = Math.floor(from.getTime() / 1000) * 1000;
|
||||||
|
while (start <= end) {
|
||||||
|
const stop = Math.min(start + step, end);
|
||||||
|
windows.push({ from: new Date(start), to: new Date(stop) });
|
||||||
|
if (stop === end) break;
|
||||||
|
start = stop + 1000;
|
||||||
|
}
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Several `visited_cities` answers folded into one, as if the whole range had
|
||||||
|
* been asked for at once.
|
||||||
|
*
|
||||||
|
* Countries keep the order they were first seen in, cities within a country
|
||||||
|
* likewise. A city seen in more than one window adds up its minutes and points
|
||||||
|
* and keeps the latest timestamp. The payload arrives unvalidated, so an entry
|
||||||
|
* without a usable name is skipped here rather than guessed at.
|
||||||
|
*
|
||||||
|
* One thing a split cannot recover: Dawarich drops a stay shorter than the
|
||||||
|
* user's minimum per window, so a stay cut in half by a window boundary can fall
|
||||||
|
* under it on both sides. That costs a city at most, never a country the rest of
|
||||||
|
* the recording found.
|
||||||
|
*/
|
||||||
|
export function mergeVisitedCountries(answers: DawarichVisitedCountry[][]): DawarichVisitedCountry[] {
|
||||||
|
const countries = new Map<string, Map<string, DawarichVisitedCountry['cities'][number]>>();
|
||||||
|
for (const answer of answers) {
|
||||||
|
for (const entry of answer) {
|
||||||
|
if (!entry || typeof entry.country !== 'string') continue;
|
||||||
|
let cities = countries.get(entry.country);
|
||||||
|
if (!cities) {
|
||||||
|
cities = new Map();
|
||||||
|
countries.set(entry.country, cities);
|
||||||
|
}
|
||||||
|
for (const city of Array.isArray(entry.cities) ? entry.cities : []) {
|
||||||
|
if (!city || typeof city.city !== 'string') continue;
|
||||||
|
const seen = cities.get(city.city);
|
||||||
|
if (!seen) {
|
||||||
|
cities.set(city.city, { ...city });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.points = sumOf(seen.points, city.points);
|
||||||
|
seen.stayed_for = sumOf(seen.stayed_for, city.stayed_for);
|
||||||
|
const latest = Math.max(toNumber(seen.timestamp) ?? -Infinity, toNumber(city.timestamp) ?? -Infinity);
|
||||||
|
seen.timestamp = Number.isFinite(latest) ? latest : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...countries].map(([country, cities]) => ({ country, cities: [...cities.values()] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two optional counts added up; absent when neither side had one. */
|
||||||
|
function sumOf(a: unknown, b: unknown): number | undefined {
|
||||||
|
const left = toNumber(a);
|
||||||
|
const right = toNumber(b);
|
||||||
|
if (left === null && right === null) return undefined;
|
||||||
|
return (left ?? 0) + (right ?? 0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -574,6 +574,66 @@ describe('DawarichClient — listVisitedCities', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DawarichClient, visited cities over a year', () => {
|
||||||
|
const YEAR_FROM = new Date('2025-09-14T20:00:00.000Z');
|
||||||
|
const YEAR_TO = new Date('2026-09-14T20:00:00.000Z');
|
||||||
|
|
||||||
|
it('DAWARICH-CLIENT-082: a year is asked for a month at a time, one request after another, and folded back', async () => {
|
||||||
|
// Dawarich computes this endpoint over every point in the window. A year in
|
||||||
|
// one request is what timed out on a tester's instance, so the client has to
|
||||||
|
// split it, and has to do so without firing thirteen computations at once.
|
||||||
|
let inFlight = 0;
|
||||||
|
let mostAtOnce = 0;
|
||||||
|
let served = 0;
|
||||||
|
safeFetchMock.mockImplementation(async (url: string, init: RequestInit) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
inFlight++;
|
||||||
|
mostAtOnce = Math.max(mostAtOnce, inFlight);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
inFlight--;
|
||||||
|
served++;
|
||||||
|
const body =
|
||||||
|
served === 1
|
||||||
|
? { data: [{ country: 'Germany', cities: [{ city: 'Rostock', stayed_for: 90 }] }] }
|
||||||
|
: served === 7
|
||||||
|
? { data: [{ country: 'Germany', cities: [{ city: 'Rostock', stayed_for: 30 }] }, { country: 'Poland', cities: [] }] }
|
||||||
|
: { data: [] };
|
||||||
|
return reply({ body }) as unknown as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
const out = await client.listVisitedCities(CREDS, YEAR_FROM, YEAR_TO);
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(13);
|
||||||
|
expect(mostAtOnce).toBe(1);
|
||||||
|
expect(requestedUrl(0).searchParams.get('start_at')).toBe(unixSeconds(YEAR_FROM));
|
||||||
|
expect(requestedUrl(12).searchParams.get('end_at')).toBe(unixSeconds(YEAR_TO));
|
||||||
|
for (let i = 1; i < calls.length; i++) {
|
||||||
|
const previousEnd = Number(requestedUrl(i - 1).searchParams.get('end_at'));
|
||||||
|
expect(Number(requestedUrl(i).searchParams.get('start_at'))).toBe(previousEnd + 1);
|
||||||
|
}
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ country: 'Germany', cities: [{ city: 'Rostock', stayed_for: 120 }] },
|
||||||
|
{ country: 'Poland', cities: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAWARICH-CLIENT-083: one month failing fails the whole year instead of passing off eleven as twelve', async () => {
|
||||||
|
let served = 0;
|
||||||
|
safeFetchMock.mockImplementation(async (url: string, init: RequestInit) => {
|
||||||
|
calls.push({ url, init });
|
||||||
|
served++;
|
||||||
|
if (served === 4) throw new Error('The operation was aborted due to timeout');
|
||||||
|
return reply({ body: { data: [] } }) as unknown as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
const err = await failure(() => client.listVisitedCities(CREDS, YEAR_FROM, YEAR_TO));
|
||||||
|
expect(err.code).toBe('unreachable');
|
||||||
|
// Stops at the failure rather than spending the remaining nine on a result
|
||||||
|
// that is already going to be refused.
|
||||||
|
expect(calls).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('DawarichClient — findVisitsNear', () => {
|
describe('DawarichClient — findVisitsNear', () => {
|
||||||
it('DAWARICH-CLIENT-090: pins the radius explicitly instead of letting Dawarich guess 500 m', async () => {
|
it('DAWARICH-CLIENT-090: pins the radius explicitly instead of letting Dawarich guess 500 m', async () => {
|
||||||
const visits = [{ timestamp: 1788000000, distance_meters: 40, points_count: 12 }];
|
const visits = [{ timestamp: 1788000000, distance_meters: 40, points_count: 12 }];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* The pure half of the Dawarich integration — DAWARICH-TIME-001 through
|
* The pure half of the Dawarich integration — DAWARICH-TIME-001 through
|
||||||
* DAWARICH-COUNT-003. Everything under test is a free function over plain
|
* DAWARICH-CITIES-006. Everything under test is a free function over plain
|
||||||
* fixtures: no DB, no container, and no client. `dawarich.client` appears here
|
* fixtures: no DB, no container, and no client. `dawarich.client` appears here
|
||||||
* only as a type-only import (erased at compile time), so nothing in this file
|
* only as a type-only import (erased at compile time), so nothing in this file
|
||||||
* can reach the network even by accident — there is no DawarichClient instance
|
* can reach the network even by accident — there is no DawarichClient instance
|
||||||
@@ -19,10 +19,12 @@ import {
|
|||||||
distanceMeters,
|
distanceMeters,
|
||||||
geoJsonToLatLng,
|
geoJsonToLatLng,
|
||||||
localDateOf,
|
localDateOf,
|
||||||
|
mergeVisitedCountries,
|
||||||
minutesBetween,
|
minutesBetween,
|
||||||
normalizeVisit,
|
normalizeVisit,
|
||||||
offsetMinutesOf,
|
offsetMinutesOf,
|
||||||
simplify,
|
simplify,
|
||||||
|
splitWindow,
|
||||||
syncWindow,
|
syncWindow,
|
||||||
toNumber,
|
toNumber,
|
||||||
visitHash,
|
visitHash,
|
||||||
@@ -927,3 +929,85 @@ describe('countPoints', () => {
|
|||||||
expect(countPoints(days)).toBe(6);
|
expect(countPoints(days)).toBe(6);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('visited cities over a long range', () => {
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
|
||||||
|
it('DAWARICH-CITIES-001: a range within one window stays one request, bounds untouched', () => {
|
||||||
|
const from = new Date('2026-09-01T00:00:00Z');
|
||||||
|
const to = new Date('2026-09-02T00:00:00Z');
|
||||||
|
expect(splitWindow(from, to, 30)).toEqual([{ from, to }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAWARICH-CITIES-002: a year becomes months that cover it without a gap or an overlap', () => {
|
||||||
|
// Dawarich reads start_at..end_at as inclusive on both ends, so touching
|
||||||
|
// bounds would hand the point on the seam to two windows.
|
||||||
|
const from = new Date('2025-09-14T20:00:00Z');
|
||||||
|
const to = new Date(from.getTime() + 365 * DAY);
|
||||||
|
const windows = splitWindow(from, to, 30);
|
||||||
|
|
||||||
|
expect(windows).toHaveLength(13);
|
||||||
|
expect(windows[0].from).toEqual(from);
|
||||||
|
expect(windows[windows.length - 1].to).toEqual(to);
|
||||||
|
for (let i = 1; i < windows.length; i++) {
|
||||||
|
expect(windows[i].from.getTime() - windows[i - 1].to.getTime()).toBe(1000);
|
||||||
|
}
|
||||||
|
for (const window of windows) {
|
||||||
|
expect(window.to.getTime() - window.from.getTime()).toBeLessThanOrEqual(30 * DAY);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAWARICH-CITIES-003: bounds are whole seconds, the unit that goes on the wire', () => {
|
||||||
|
const windows = splitWindow(new Date('2026-01-01T00:00:00.750Z'), new Date('2026-03-01T00:00:00.250Z'), 30);
|
||||||
|
for (const window of windows) {
|
||||||
|
expect(window.from.getMilliseconds()).toBe(0);
|
||||||
|
expect(window.to.getMilliseconds()).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAWARICH-CITIES-004: a range that ends before it starts asks nothing', () => {
|
||||||
|
expect(splitWindow(new Date('2026-09-02T00:00:00Z'), new Date('2026-09-01T00:00:00Z'), 30)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAWARICH-CITIES-005: a city seen in two months adds up its time and keeps the later visit', () => {
|
||||||
|
const merged = mergeVisitedCountries([
|
||||||
|
[{ country: 'Germany', cities: [{ city: 'Rostock', points: 10, stayed_for: 90, timestamp: 1_700_000_000 }] }],
|
||||||
|
[
|
||||||
|
{ country: 'Germany', cities: [{ city: 'Rostock', points: 5, stayed_for: 60, timestamp: 1_702_000_000 }, { city: 'Berlin', stayed_for: 120 }] },
|
||||||
|
{ country: 'Poland', cities: [{ city: 'Szczecin', stayed_for: 75, timestamp: 1_701_000_000 }] },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(merged).toEqual([
|
||||||
|
{
|
||||||
|
country: 'Germany',
|
||||||
|
cities: [
|
||||||
|
{ city: 'Rostock', points: 15, stayed_for: 150, timestamp: 1_702_000_000 },
|
||||||
|
{ city: 'Berlin', stayed_for: 120 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ country: 'Poland', cities: [{ city: 'Szczecin', stayed_for: 75, timestamp: 1_701_000_000 }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DAWARICH-CITIES-006: an unvalidated payload loses its unnamed entries, not its countries', () => {
|
||||||
|
const merged = mergeVisitedCountries([
|
||||||
|
[
|
||||||
|
null as never,
|
||||||
|
{ country: 42 as never, cities: [] },
|
||||||
|
{ country: 'Denmark', cities: null as never },
|
||||||
|
],
|
||||||
|
[{ country: 'Denmark', cities: [null as never, {} as never, { city: 'Gedser', points: '7' as never }] }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A count that arrived as a string is still a count; a timestamp nobody sent
|
||||||
|
// stays absent instead of turning into -Infinity.
|
||||||
|
expect(merged).toEqual([{ country: 'Denmark', cities: [{ city: 'Gedser', points: '7' }] }]);
|
||||||
|
|
||||||
|
const again = mergeVisitedCountries([
|
||||||
|
[{ country: 'Denmark', cities: [{ city: 'Gedser', points: '7' as never }] }],
|
||||||
|
[{ country: 'Denmark', cities: [{ city: 'Gedser', points: 3 }] }],
|
||||||
|
]);
|
||||||
|
expect(again[0].cities[0]).toEqual({ city: 'Gedser', points: 10, stayed_for: undefined, timestamp: undefined });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user