all is well
Some checks failed
Build and publish Docker image / build-and-push (push) Failing after 7m0s
CI / Check (push) Failing after 7m9s

This commit is contained in:
Andras Schmelczer 2026-05-17 17:20:19 +01:00
parent eac1bd0d13
commit 2f149503bb
53 changed files with 1543 additions and 354 deletions

View file

@ -206,7 +206,6 @@ export function useHexagonSelection({
const params = new URLSearchParams({
h3,
resolution: res.toString(),
limit: '100',
offset: offset.toString(),
});
@ -250,7 +249,6 @@ export function useHexagonSelection({
try {
const params = new URLSearchParams({
postcode,
limit: '100',
offset: offset.toString(),
});
if (focusAddress && offset === 0) {

View file

@ -127,7 +127,7 @@ export function useLocationSearch(mode?: string) {
const controller = new AbortController();
abortRef.current = controller;
try {
const params = new URLSearchParams({ q: trimmed, limit: '20' });
const params = new URLSearchParams({ q: trimmed });
if (mode) params.set('mode', mode);
const res = await fetch(
`/api/places?${params}`,

View file

@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import type { NearbyStation, GeoPoint } from '../lib/nearby-stations';
import {
STATION_CATEGORIES,
selectNearbyStations,
stationSearchBounds,
} from '../lib/nearby-stations';
import type { POIResponse } from '../types';
import { apiUrl, assertOk, authHeaders, logNonAbortError } from '../lib/api';
function boundsParam(bounds: ReturnType<typeof stationSearchBounds>): string {
return `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
}
export function useNearbyStations(origin: GeoPoint | null) {
const [stations, setStations] = useState<NearbyStation[]>([]);
const [loading, setLoading] = useState(false);
const lat = origin?.lat;
const lon = origin?.lon;
useEffect(() => {
if (lat == null || lon == null) {
setStations([]);
setLoading(false);
return;
}
const controller = new AbortController();
setStations([]);
setLoading(true);
const originPoint = { lat, lon };
const bounds = stationSearchBounds(originPoint);
const params = new URLSearchParams({
bounds: boundsParam(bounds),
categories: STATION_CATEGORIES.join(','),
});
fetch(apiUrl('pois', params), authHeaders({ signal: controller.signal }))
.then((response) => {
assertOk(response, 'nearby stations');
return response.json() as Promise<POIResponse>;
})
.then((json) => {
if (!controller.signal.aborted) {
setStations(selectNearbyStations(json.pois ?? [], originPoint));
}
})
.catch((error) => logNonAbortError('Failed to fetch nearby stations', error))
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [lat, lon]);
return { stations, loading };
}

View file

@ -44,7 +44,7 @@ function getPoiIconUrlForPoi(poi: POI): string {
}
function isBundledPoiIcon(url: string): boolean {
return url.startsWith('/assets/poi-icons/');
return url.startsWith('/assets/poi-icons/') || url.startsWith('data:image/svg+xml');
}
function hasBundledPoiLogo(poi: POI): boolean {

View file

@ -0,0 +1,80 @@
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import type { MutableRefObject } from 'react';
import { useRetainedScrollTop } from './useRetainedScrollTop';
function ScrollPane({
restoreKey,
savedScrollTopRef,
suspendSave,
}: {
restoreKey: string;
savedScrollTopRef: MutableRefObject<number>;
suspendSave: boolean;
}) {
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
restoreKey,
scrollTopRef: savedScrollTopRef,
suspendSave,
});
return (
<div ref={scrollRef} onScroll={onScroll} data-testid="pane">
<div style={{ height: 2000 }} />
</div>
);
}
describe('useRetainedScrollTop', () => {
afterEach(() => {
cleanup();
});
it('keeps the saved scroll offset while replacement content is loading', () => {
const savedScrollTopRef = { current: 0 };
const view = render(
<ScrollPane
restoreKey="area:a"
savedScrollTopRef={savedScrollTopRef}
suspendSave={false}
/>
);
const pane = view.getByTestId('pane');
pane.scrollTop = 360;
fireEvent.scroll(pane);
expect(savedScrollTopRef.current).toBe(360);
view.rerender(
<ScrollPane restoreKey="area:b" savedScrollTopRef={savedScrollTopRef} suspendSave />
);
pane.scrollTop = 0;
fireEvent.scroll(pane);
expect(savedScrollTopRef.current).toBe(360);
view.rerender(
<ScrollPane
restoreKey="area:b"
savedScrollTopRef={savedScrollTopRef}
suspendSave={false}
/>
);
expect(pane.scrollTop).toBe(360);
});
it('restores the saved offset when a pane remounts', () => {
const savedScrollTopRef = { current: 220 };
const view = render(
<ScrollPane
restoreKey="area:a"
savedScrollTopRef={savedScrollTopRef}
suspendSave={false}
/>
);
expect(view.getByTestId('pane').scrollTop).toBe(220);
});
});

View file

@ -0,0 +1,63 @@
import { useCallback, useLayoutEffect, useRef, type MutableRefObject, type UIEvent } from 'react';
interface UseRetainedScrollTopOptions {
restoreKey: string | null;
scrollTopRef?: MutableRefObject<number>;
suspendSave?: boolean;
}
export function useRetainedScrollTop<T extends HTMLElement>({
restoreKey,
scrollTopRef,
suspendSave = false,
}: UseRetainedScrollTopOptions) {
const fallbackScrollTopRef = useRef(0);
const savedScrollTopRef = scrollTopRef ?? fallbackScrollTopRef;
const nodeRef = useRef<T | null>(null);
const previousRestoreKeyRef = useRef(restoreKey);
const pendingRestoreTopRef = useRef<number | null>(null);
const suspendSaveRef = useRef(suspendSave);
suspendSaveRef.current = suspendSave;
const scrollRef = useCallback(
(node: T | null) => {
const previousNode = nodeRef.current;
if (!node && previousNode && !suspendSaveRef.current) {
savedScrollTopRef.current = previousNode.scrollTop;
}
nodeRef.current = node;
if (node) {
node.scrollTop = pendingRestoreTopRef.current ?? savedScrollTopRef.current;
}
},
[savedScrollTopRef]
);
const onScroll = useCallback(
(event: UIEvent<T>) => {
if (suspendSaveRef.current) return;
savedScrollTopRef.current = event.currentTarget.scrollTop;
},
[savedScrollTopRef]
);
useLayoutEffect(() => {
if (previousRestoreKeyRef.current !== restoreKey) {
previousRestoreKeyRef.current = restoreKey;
pendingRestoreTopRef.current = savedScrollTopRef.current;
}
const node = nodeRef.current;
const pendingRestoreTop = pendingRestoreTopRef.current;
if (!node || pendingRestoreTop == null) return;
node.scrollTop = pendingRestoreTop;
if (!suspendSave) {
pendingRestoreTopRef.current = null;
}
}, [restoreKey, savedScrollTopRef, suspendSave]);
return { scrollRef, onScroll };
}