diff --git a/client/src/api/dawarich.test.ts b/client/src/api/dawarich.test.ts index 3230b5823..7592420ad 100644 --- a/client/src/api/dawarich.test.ts +++ b/client/src/api/dawarich.test.ts @@ -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() + }) +}) diff --git a/client/src/api/dawarich.ts b/client/src/api/dawarich.ts index d2c3ff295..e63eac8c4 100644 --- a/client/src/api/dawarich.ts +++ b/client/src/api/dawarich.ts @@ -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 => @@ -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): Promise => - 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 => @@ -49,7 +71,7 @@ export const dawarichApi = { // ── Bucket list ──────────────────────────────────────────────────────────── scanBucketList: (): Promise => - 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 => - 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 => 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 => 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), } diff --git a/client/src/components/Map/DawarichTrailLayer.tsx b/client/src/components/Map/DawarichTrailLayer.tsx index 2a4aba884..33284d3fe 100644 --- a/client/src/components/Map/DawarichTrailLayer.tsx +++ b/client/src/components/Map/DawarichTrailLayer.tsx @@ -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 | 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 ( diff --git a/client/src/components/Map/DawarichTrailPill.test.tsx b/client/src/components/Map/DawarichTrailPill.test.tsx index 7cff5608b..3c1fcce36 100644 --- a/client/src/components/Map/DawarichTrailPill.test.tsx +++ b/client/src/components/Map/DawarichTrailPill.test.tsx @@ -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() + 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() + expect(screen.queryByTestId('dawarich-trail-loading')).not.toBeInTheDocument() + }) }) diff --git a/client/src/components/Map/DawarichTrailPill.tsx b/client/src/components/Map/DawarichTrailPill.tsx index 471f0020d..8d18b0c52 100644 --- a/client/src/components/Map/DawarichTrailPill.tsx +++ b/client/src/components/Map/DawarichTrailPill.tsx @@ -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 (
+ {loading && ( +
) } diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 96e0b9412..d363a1b75 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -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({ {poiMarkers} diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 5f57fdb90..f21dddf04 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -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 | null /** Via points to draw as draggable handles, keyed by day (#1797). */ roadtripVias?: Record 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>(new Map()) // Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin). diff --git a/client/src/components/Map/dawarichTrail.test.ts b/client/src/components/Map/dawarichTrail.test.ts index 65c335e81..4c677458c 100644 --- a/client/src/components/Map/dawarichTrail.test.ts +++ b/client/src/components/Map/dawarichTrail.test.ts @@ -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 `` 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']) + }) +}) diff --git a/client/src/components/Map/dawarichTrail.ts b/client/src/components/Map/dawarichTrail.ts index 5d56408d3..97adec6af 100644 --- a/client/src/components/Map/dawarichTrail.ts +++ b/client/src/components/Map/dawarichTrail.ts @@ -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 | 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 | null, + collapsedDayIds?: ReadonlySet | null, +): string[] { + const dates = new Set() + 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' diff --git a/client/src/components/Map/useDawarichTrailGL.test.ts b/client/src/components/Map/useDawarichTrailGL.test.ts index db241f567..194b6a57f 100644 --- a/client/src/components/Map/useDawarichTrailGL.test.ts +++ b/client/src/components/Map/useDawarichTrailGL.test.ts @@ -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 }; before: string | undefined }>() for (const id of styleLayers) layers.set(id, { spec: { id }, before: undefined }) const listeners = new Map void>>() + const onceListeners = new Map 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 | 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) + }) }) diff --git a/client/src/components/Map/useDawarichTrailGL.ts b/client/src/components/Map/useDawarichTrailGL.ts index 474579282..25a8f3a01 100644 --- a/client/src/components/Map/useDawarichTrailGL.ts +++ b/client/src/components/Map/useDawarichTrailGL.ts @@ -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 | 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]) } diff --git a/client/src/components/Map/useHazardLayerGL.test.ts b/client/src/components/Map/useHazardLayerGL.test.ts new file mode 100644 index 000000000..793821c10 --- /dev/null +++ b/client/src/components/Map/useHazardLayerGL.test.ts @@ -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() + const layers = new Set() + const once = new Map 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) + }) +}) diff --git a/client/src/components/Map/useHazardLayerGL.ts b/client/src/components/Map/useHazardLayerGL.ts index 123c363e7..9c1d18f33 100644 --- a/client/src/components/Map/useHazardLayerGL.ts +++ b/client/src/components/Map/useHazardLayerGL.ts @@ -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) diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 494153443..cfa9bc6bd 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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 { !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, diff --git a/server/src/nest/integrations/dawarich.client.ts b/server/src/nest/integrations/dawarich.client.ts index a5b180b33..05a1b6814 100644 --- a/server/src/nest/integrations/dawarich.client.ts +++ b/server/src/nest/integrations/dawarich.client.ts @@ -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 { - 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); } /** diff --git a/server/src/nest/integrations/dawarich.helpers.ts b/server/src/nest/integrations/dawarich.helpers.ts index e93ca2250..428666290 100644 --- a/server/src/nest/integrations/dawarich.helpers.ts +++ b/server/src/nest/integrations/dawarich.helpers.ts @@ -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>(); + 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); +} diff --git a/server/tests/unit/nest/dawarich.client.test.ts b/server/tests/unit/nest/dawarich.client.test.ts index b1e926fd7..7cb59a2e8 100644 --- a/server/tests/unit/nest/dawarich.client.test.ts +++ b/server/tests/unit/nest/dawarich.client.test.ts @@ -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 }]; diff --git a/server/tests/unit/nest/dawarich.helpers.test.ts b/server/tests/unit/nest/dawarich.helpers.test.ts index 269e5d391..b835a412b 100644 --- a/server/tests/unit/nest/dawarich.helpers.test.ts +++ b/server/tests/unit/nest/dawarich.helpers.test.ts @@ -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 }); + }); +});