mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Merge pull request #2372 from liketrek/fix/dawarich-tester-feedback
fix(dawarich): three things a tester on dev1 ran into
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
|
||||
* 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 { http, HttpResponse, delay, type JsonBodyType } from 'msw'
|
||||
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 {
|
||||
DawarichAtlasSuggestions,
|
||||
DawarichBucketScan,
|
||||
@@ -404,3 +406,54 @@ describe('dawarichApi track overlay', () => {
|
||||
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
|
||||
* 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 = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
getSettings: (): Promise<DawarichConnection> =>
|
||||
@@ -31,13 +53,13 @@ export const dawarichApi = {
|
||||
/** The address it resolved to, for the private-IP warning. */
|
||||
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 }> =>
|
||||
apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
|
||||
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 }> =>
|
||||
apiClient.post('/integrations/dawarich/sync').then(r => r.data),
|
||||
apiClient.post('/integrations/dawarich/sync', undefined, upstream).then(r => r.data),
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
listSuggestions: (params?: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> =>
|
||||
@@ -49,7 +71,7 @@ export const dawarichApi = {
|
||||
|
||||
// ── Bucket list ────────────────────────────────────────────────────────────
|
||||
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 }> =>
|
||||
apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
|
||||
clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
|
||||
@@ -57,7 +79,9 @@ export const dawarichApi = {
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
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 }> =>
|
||||
apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
|
||||
|
||||
@@ -69,11 +93,11 @@ export const dawarichApi = {
|
||||
// the wrong day of the trip.
|
||||
tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||
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),
|
||||
windowTrack: (from: string, to: string, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@@ -26,15 +26,18 @@ import {
|
||||
export default function DawarichTrailLayer({
|
||||
track,
|
||||
selectedDate,
|
||||
hiddenDates,
|
||||
casingPane,
|
||||
}: {
|
||||
track: DawarichTrack | null
|
||||
/** When set, only that local day is drawn. */
|
||||
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. */
|
||||
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
|
||||
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
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
|
||||
// there is nothing to show.
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
padding: 4,
|
||||
@@ -64,6 +69,7 @@ export function DawarichTrailPill({
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
aria-busy={loading}
|
||||
data-testid="dawarich-trail-pill"
|
||||
style={{
|
||||
display: 'block',
|
||||
@@ -88,6 +94,22 @@ export function DawarichTrailPill({
|
||||
<DawarichIcon fill />
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -676,6 +676,7 @@ export const MapView = memo(function MapView({
|
||||
hazards,
|
||||
dawarichTrack = null,
|
||||
dawarichSelectedDate = null,
|
||||
dawarichHiddenDates = null,
|
||||
accessLines = [],
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
@@ -1201,6 +1202,7 @@ export const MapView = memo(function MapView({
|
||||
<DawarichTrailLayer
|
||||
track={dawarichTrack}
|
||||
selectedDate={dawarichSelectedDate}
|
||||
hiddenDates={dawarichHiddenDates}
|
||||
casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined}
|
||||
/>
|
||||
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
|
||||
|
||||
@@ -197,6 +197,8 @@ interface Props {
|
||||
dawarichTrack?: DawarichTrack | null
|
||||
/** Draw only this local day of the recording. */
|
||||
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). */
|
||||
roadtripVias?: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
@@ -646,6 +648,7 @@ export function MapViewGL({
|
||||
hazards,
|
||||
dawarichTrack = null,
|
||||
dawarichSelectedDate = null,
|
||||
dawarichHiddenDates = null,
|
||||
dayOrderMap = NO_DAY_ORDER,
|
||||
leftWidth = 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])
|
||||
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
||||
// 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
|
||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||
// 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
|
||||
* recorded route: Leaflet turns `trailSegments` into `<Polyline>` elements, the
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
DAWARICH_DAY_COLORS,
|
||||
DAWARICH_TRAIL_CASING,
|
||||
DAWARICH_TRAIL_COLOR,
|
||||
collapsedDayDates,
|
||||
dayColor,
|
||||
trailDistanceKm,
|
||||
trailGeoJson,
|
||||
@@ -227,3 +229,40 @@ describe('trailDistanceKm', () => {
|
||||
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
|
||||
* planner should dim the rest of the trip rather than reload it, so the filter
|
||||
* happens here over data that is already in memory.
|
||||
* Both filters work on data that is already in memory: selecting or collapsing a
|
||||
* day changes what is drawn, it does not reload the recording.
|
||||
*/
|
||||
export function trailSegments(
|
||||
track: DawarichTrack | null,
|
||||
onlyDate?: string | null,
|
||||
hiddenDates?: ReadonlySet<string> | null,
|
||||
): DawarichTrailSegment[] {
|
||||
if (!track) return []
|
||||
const days: DawarichTrackDay[] = onlyDate
|
||||
? track.days.filter(day => day.date === onlyDate)
|
||||
: track.days
|
||||
const days: DawarichTrackDay[] = track.days.filter(
|
||||
day => (!onlyDate || day.date === onlyDate) && !hiddenDates?.has(day.date),
|
||||
)
|
||||
|
||||
const out: DawarichTrailSegment[] = []
|
||||
for (const day of days) {
|
||||
@@ -108,6 +109,30 @@ export function trailSegments(
|
||||
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. */
|
||||
export function trailGeoJson(segments: DawarichTrailSegment[]): {
|
||||
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`.
|
||||
*
|
||||
* 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
|
||||
* difference between a line that blinks every two minutes and one that does
|
||||
* 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
|
||||
* refresh, and a cleanup that forgot to unsubscribe would add one silent
|
||||
* 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 }>()
|
||||
for (const id of styleLayers) layers.set(id, { spec: { id }, before: undefined })
|
||||
const listeners = new Map<string, Set<() => void>>()
|
||||
const onceListeners = new Map<string, Set<() => void>>()
|
||||
|
||||
const state = { styleLoaded }
|
||||
const map = {
|
||||
@@ -84,7 +89,15 @@ function fakeGlMap({ styleLoaded = true, styleLayers = [] as string[] } = {}) {
|
||||
set.add(fn)
|
||||
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 {
|
||||
@@ -95,6 +108,13 @@ function fakeGlMap({ styleLoaded = true, styleLayers = [] as string[] } = {}) {
|
||||
state,
|
||||
styleLoadListeners: () => listeners.get('style.load')?.size ?? 0,
|
||||
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. */
|
||||
dropStyle: () => { sources.clear(); layers.clear() },
|
||||
drawn: () => sources.get(SOURCE)?.data as
|
||||
@@ -109,10 +129,11 @@ interface Props {
|
||||
track?: DawarichTrack | null
|
||||
date?: string | null
|
||||
before?: string
|
||||
hidden?: ReadonlySet<string> | null
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -314,4 +335,53 @@ describe('useDawarichTrailGL', () => {
|
||||
// change per refresh, for as long as the map lives.
|
||||
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,
|
||||
/** Insert beneath this layer, so the planned route stays on top. */
|
||||
beforeId?: string,
|
||||
/** Local dates whose day is folded away in the day plan. */
|
||||
hiddenDates?: ReadonlySet<string> | null,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
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 = () => {
|
||||
if (!map.isStyleLoaded()) return
|
||||
if (!map.isStyleLoaded()) {
|
||||
if (!waiting) {
|
||||
waiting = true
|
||||
map.once('idle', retry)
|
||||
}
|
||||
return
|
||||
}
|
||||
const existing = map.getSource(SOURCE) as GeoJSONSource | undefined
|
||||
if (existing) {
|
||||
// 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()
|
||||
map.on('style.load', draw)
|
||||
return () => {
|
||||
map.off('style.load', draw)
|
||||
map.off('idle', retry)
|
||||
for (const layer of [LINE_LAYER, CASING_LAYER]) {
|
||||
if (map.getLayer(layer)) map.removeLayer(layer)
|
||||
}
|
||||
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 layers = [`${source}-fill`, `${source}-line`, `${source}-point`]
|
||||
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 = () => {
|
||||
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()
|
||||
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 } })
|
||||
@@ -27,12 +38,17 @@ export function useHazardLayerGL(map: Map | null, ready: boolean, hazards: Roadt
|
||||
detail?.remove()
|
||||
detail = popup().setLngLat(event.lngLat).setDOMContent(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point'))).addTo(map)
|
||||
}
|
||||
const retry = () => {
|
||||
waiting = false
|
||||
draw()
|
||||
}
|
||||
draw()
|
||||
map.on('style.load', draw)
|
||||
map.on('click', click)
|
||||
return () => {
|
||||
detail?.remove()
|
||||
map.off('style.load', draw)
|
||||
map.off('idle', retry)
|
||||
map.off('click', click)
|
||||
for (const layer of layers) if (map.getLayer(layer)) map.removeLayer(layer)
|
||||
if (map.getSource(source)) map.removeSource(source)
|
||||
|
||||
@@ -261,7 +261,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||
overviewActive, tripOverview, toggleOverview, overviewShown,
|
||||
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail,
|
||||
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates,
|
||||
followTrack, roadtripViaCounts,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
@@ -415,6 +415,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<MapView
|
||||
tripId={tripId}
|
||||
dawarichTrack={dawarichTrail.track}
|
||||
dawarichHiddenDates={dawarichHiddenDates}
|
||||
places={roadtripActive ? roadtripMapPlaces : mapPlaces}
|
||||
dayPlaces={dayPlaces}
|
||||
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 { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
||||
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
|
||||
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
|
||||
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
||||
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
|
||||
@@ -955,6 +956,19 @@ export function useTripPlanner() {
|
||||
return plannedPlaces.filter(p => !hidden.has(p.id))
|
||||
}, [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(
|
||||
() => {
|
||||
if (!roadtripSettings.roadtrip_day_colors) return undefined
|
||||
@@ -2390,7 +2404,7 @@ export function useTripPlanner() {
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||
overviewShown, toggleOverview, overviewActive, tripOverview,
|
||||
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichEnabled: !!enabledAddons.dawarich,
|
||||
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled: !!enabledAddons.dawarich,
|
||||
followTrack, roadtripViaCounts,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import type { DawarichErrorCode } from '@trek/shared';
|
||||
import { safeFetch } from '../../utils/ssrfGuard';
|
||||
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).
|
||||
@@ -385,17 +386,31 @@ export class DawarichClient {
|
||||
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(
|
||||
creds: DawarichCreds,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<DawarichVisitedCountry[]> {
|
||||
const { data } = await this.get<{ data?: DawarichVisitedCountry[] }>(creds, '/countries/visited_cities', {
|
||||
start_at: unixSeconds(from),
|
||||
end_at: unixSeconds(to),
|
||||
});
|
||||
return Array.isArray(data?.data) ? data.data : [];
|
||||
const answers: DawarichVisitedCountry[][] = [];
|
||||
for (const window of splitWindow(from, to, VISITED_CITIES_WINDOW_DAYS)) {
|
||||
const { data } = await this.get<{ data?: DawarichVisitedCountry[] }>(creds, '/countries/visited_cities', {
|
||||
start_at: unixSeconds(window.from),
|
||||
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 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
|
||||
@@ -514,3 +514,88 @@ export function countPoints(days: DawarichTrackDay[]): number {
|
||||
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', () => {
|
||||
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 }];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
@@ -19,10 +19,12 @@ import {
|
||||
distanceMeters,
|
||||
geoJsonToLatLng,
|
||||
localDateOf,
|
||||
mergeVisitedCountries,
|
||||
minutesBetween,
|
||||
normalizeVisit,
|
||||
offsetMinutesOf,
|
||||
simplify,
|
||||
splitWindow,
|
||||
syncWindow,
|
||||
toNumber,
|
||||
visitHash,
|
||||
@@ -927,3 +929,85 @@ describe('countPoints', () => {
|
||||
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