fix(maps): stretches beside the overpass probe wait for it, empty note only after a full search

This commit is contained in:
Maurice
2026-10-21 11:43:04 +02:00
parent bf2efca8f0
commit 924e38472e
5 changed files with 84 additions and 24 deletions
@@ -67,8 +67,9 @@ function WarningNotice({ text, search }: { text: string; search?: TourPoiSearch
* What the search answered beyond its places: offline, failed, kinds that
* could not be searched, partly searched, cut, or empty. That the answer is
* out of date the search button says, as it is what to press then. Nothing
* found is said only when every kind was searched, with the next wider
* corridor one press away; a failure or a gap carries its own retry.
* found is said only when every kind was searched along the whole line, with
* the next wider corridor one press away; a failure or a gap carries its own
* retry.
*/
export function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit }) {
const { t } = useTranslation();
@@ -85,12 +86,14 @@ export function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: U
{ text: answer.truncated && t('tours.poi.truncated'), retry: false },
].filter((note): note is { text: string; retry: boolean } => !!note.text);
const wider = search.widerCorridorM;
// A gap wants a retry, not a wider corridor, and changed choices were not searched yet.
const empty = answer.pois.length === 0 && answer.unavailable.length === 0 && !answer.partial && !search.outdated;
return (
<>
{notes.map((note) => (
<WarningNotice key={note.text} text={note.text} search={note.retry ? search : undefined} />
))}
{answer.pois.length === 0 && answer.unavailable.length === 0 && (
{empty && (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5">
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
{t('tours.poi.empty', { distance: formatDistance(km(search.corridorM), unit) })}
@@ -14,7 +14,7 @@ import { usePoiInsertion, type PoiInsertionPlanner } from './usePoiInsertion';
import { useTourPoiMarkers } from './useTourPoiMarkers';
import { useTourPoiSearch } from './useTourPoiSearch';
// FE-TOUR-POI-001 to FE-TOUR-POI-026
// FE-TOUR-POI-001 to FE-TOUR-POI-027
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
@@ -656,4 +656,15 @@ describe('a search that could not ask, and its retry (PLN-05)', () => {
await waitFor(() => expect(screen.getByTestId('tour-poi-search')).toBeEnabled());
expect(screen.queryByTestId('tour-poi-widen')).not.toBeInTheDocument();
});
it('FE-TOUR-POI-027: an empty answer with stretches left out offers a retry, never nothing found or a wider corridor', async () => {
search.mockResolvedValue({ ...ANSWER, pois: [], partial: true });
render(<TourPoiSearchCard planner={searchPlanner()} />);
fireEvent.click(screen.getByTestId('tour-poi-search'));
expect(await screen.findByText(/Some stretches could not be searched/)).toBeInTheDocument();
expect(screen.getByTestId('tour-poi-retry')).toHaveTextContent('Try again');
expect(screen.queryByText(/Nothing found/)).not.toBeInTheDocument();
expect(screen.queryByTestId('tour-poi-widen')).not.toBeInTheDocument();
});
});
+32 -6
View File
@@ -347,7 +347,8 @@ export const OVERPASS_ALONG_COOLDOWN_MS = 3 * 60 * 1000;
/**
* How often a search along a line may probe Overpass during the cooldown; a
* busy mirror's Retry-After stretches it, never beyond the cooldown. The
* searches beside the probe are refused at once, as in the whole cooldown.
* searches beside the probe wait for its answer and ask after it if it ended
* the cooldown; outside a probe they are refused at once.
*/
export const OVERPASS_ALONG_PROBE_MS = 20 * 1000;
@@ -375,6 +376,8 @@ export class OsmClient {
private overpassAlongCooldownUntil = 0;
/** When the next probe of the cooldown may go out. */
private overpassAlongProbeAt = 0;
/** The probe in flight, which the stretches beside it wait for. */
private overpassAlongProbe: Promise<unknown> | null = null;
/** When an along query was last answered, so a failure that settles after an answer starts no cooldown. */
private overpassAlongAnsweredAt = 0;
/** Whether this cooldown's first skip was logged, so the log holds one line per cooldown. */
@@ -784,7 +787,8 @@ export class OsmClient {
* unnamed garden or cafe is dropped as on the explore pill. A short deadline
* of its own; failing throws as the explore pill's search does. During the
* cooldown after every endpoint failed, a stretch still cached is answered
* from the cache and any other throws OverpassCoolingDown at once.
* from the cache, one beside the probe waits for it, and any other throws
* OverpassCoolingDown at once.
*/
async searchOverpassPoisAlong(
category: string,
@@ -803,11 +807,17 @@ export class OsmClient {
const cached = cachedPoiAnswer(cacheKey);
if (cached) return cached;
// Neither a new search nor a later stretch of one under way waits for mirrors that just failed,
// but one in a while probes them, so a retry finds them back.
if (this.skipsOverpassAlong()) throw new OverpassCoolingDown();
if (Date.now() < this.overpassAlongCooldownUntil) this.overpassAlongProbeAt = Date.now() + OVERPASS_ALONG_PROBE_MS;
// but one in a while probes them, so a retry finds them back. A stretch beside the probe waits
// for its answer instead, so a probe that ends the cooldown answers its whole search.
if (this.overpassAlongProbe !== null || this.skipsOverpassAlong()) {
await this.overpassAlongProbe?.catch(() => undefined);
if (this.skipsOverpassAlong()) throw new OverpassCoolingDown();
}
const elements = await this.fetchOverpassAlong(overpassAlongQuery(match.filters, path, cap));
const query = overpassAlongQuery(match.filters, path, cap);
const elements = await (Date.now() < this.overpassAlongCooldownUntil
? this.probeOverpassAlong(query)
: this.fetchOverpassAlong(query));
const pois = readPois(elements, match, osmLang, KEPT_UNNAMED_ALONG);
const value: PoiSearchResult = {
pois: pois.slice(0, cap),
@@ -844,6 +854,22 @@ export class OsmClient {
return Math.max(0, Math.min(this.overpassAlongProbeAt, this.overpassAlongCooldownUntil) - now);
}
/**
* The along query sent as the cooldown's probe. Until it settles no other
* probe goes out, even once the next is due, since asking every mirror in
* turn can outlast OVERPASS_ALONG_PROBE_MS.
*/
private async probeOverpassAlong(query: string): Promise<OverpassPoiElement[]> {
this.overpassAlongProbeAt = Date.now() + OVERPASS_ALONG_PROBE_MS;
const probe = this.fetchOverpassAlong(query);
this.overpassAlongProbe = probe;
try {
return await probe;
} finally {
this.overpassAlongProbe = null;
}
}
/**
* The along query on the mirrors, one after another. An answer ends the
* cooldown; every endpoint failing starts or stretches it, unless another
+33 -13
View File
@@ -5,8 +5,9 @@
* because they are what a hike looks for, while an unnamed garden or cafe is
* not. After every mirror failed, the next queries are refused at once for a
* cooldown, unless Overpass answered meanwhile, the answer is still cached or
* a probe is due (PLN-05). The mirrors are asked one after another, a busy
* one's Retry-After heeded. The network is a stubbed global fetch.
* a probe is due, and a stretch beside the probe waits for it (PLN-05). The
* mirrors are asked one after another, a busy one's Retry-After heeded. The
* network is a stubbed global fetch.
*/
import {
OVERPASS_ALONG_COOLDOWN_MS,
@@ -241,32 +242,51 @@ describe('OsmClient.searchOverpassPoisAlong', () => {
}
});
it('MAPS-ALONG-032: during the cooldown one probe goes through, the searches beside it are refused, its answer ends it', async () => {
it('MAPS-ALONG-032: during the cooldown one probe goes through, the stretches beside it wait for its answer', async () => {
vi.useFakeTimers({ now: new Date('2026-10-21T05:06:00Z'), toFake: ['Date'] });
const fetchMock = vi.fn().mockResolvedValue(down());
vi.stubGlobal('fetch', fetchMock);
const settle = () => new Promise((resolve) => setImmediate(resolve));
try {
await expect(osm.searchOverpassPoisAlong('viewpoint', line(31), 500)).rejects.toMatchObject({ status: 502 });
expect(osm.overpassAlongRetryAfterMs()).toBe(OVERPASS_ALONG_PROBE_MS);
const asked = fetchMock.mock.calls.length;
// The mirrors are back by the time the probe is due: it asks, the search beside it does not.
// The probe fails again: the stretch beside it asks nothing and is refused once the probe is.
vi.setSystemTime(new Date('2026-10-21T05:06:20Z'));
fetchMock.mockResolvedValue(
answer([{ type: 'node', id: 31, lat: 47.715, lon: 11.1, tags: { tourism: 'viewpoint' } }]),
);
const probe = osm.searchOverpassPoisAlong('viewpoint', line(31), 500);
const failing = expect(osm.searchOverpassPoisAlong('viewpoint', line(31), 500)).rejects.toMatchObject({
status: 502,
});
await expect(osm.searchOverpassPoisAlong('viewpoint', line(33), 500)).rejects.toBeInstanceOf(OverpassCoolingDown);
await expect(probe).resolves.toMatchObject({ pois: [expect.objectContaining({ osm_id: 'node:31' })] });
expect(fetchMock).toHaveBeenCalledTimes(asked + 1);
await failing;
expect(fetchMock).toHaveBeenCalledTimes(2 * asked);
// Its answer ends the cooldown: the next search asks at once.
// The mirrors are back by the next probe. It is still in flight when the one after would be due,
// yet no second probe goes out: the stretch beside it waits, then asks once the probe answered.
vi.setSystemTime(new Date('2026-10-21T05:06:40Z'));
let release: (value: unknown) => void = () => {};
fetchMock.mockImplementationOnce(() => new Promise((resolve) => (release = resolve)));
const found = answer([{ type: 'node', id: 31, lat: 47.715, lon: 11.1, tags: { tourism: 'viewpoint' } }]);
fetchMock.mockResolvedValue(found);
const probe = osm.searchOverpassPoisAlong('viewpoint', line(31), 500);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2 * asked + 1));
vi.setSystemTime(new Date(Date.now() + OVERPASS_ALONG_PROBE_MS + 1));
const beside = osm.searchOverpassPoisAlong('viewpoint', line(33), 500);
await settle();
expect(fetchMock).toHaveBeenCalledTimes(2 * asked + 1);
release(found);
await expect(probe).resolves.toMatchObject({ pois: [expect.objectContaining({ osm_id: 'node:31' })] });
await expect(beside).resolves.toMatchObject({ source: 'openstreetmap' });
expect(fetchMock).toHaveBeenCalledTimes(2 * asked + 2);
// Its answer ended the cooldown: the next search asks at once.
expect(osm.skipsOverpassAlong()).toBe(false);
expect(osm.overpassAlongRetryAfterMs()).toBe(0);
await expect(osm.searchOverpassPoisAlong('viewpoint', line(33), 500)).resolves.toMatchObject({
await expect(osm.searchOverpassPoisAlong('viewpoint', line(32), 500)).resolves.toMatchObject({
source: 'openstreetmap',
});
expect(fetchMock).toHaveBeenCalledTimes(asked + 2);
expect(fetchMock).toHaveBeenCalledTimes(2 * asked + 3);
} finally {
vi.useRealTimers();
}
+1 -1
View File
@@ -242,7 +242,7 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
| `get_city_walk_progress` | How far a city walk got on one day it is on: every station in order with its key, its status that day (planned, visited or skipped, as checked off in the day plan), importance, protection, stay and opening hours, what is still open, and the walking time of the whole walk. Visits belong to that day only. Reading changes nothing | `trips:read` |
| `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` |
| `search_tour_pois` | Places along the saved line of a hike, run, ride, bike tour, paddle tour, ski and winter tour or city walk within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental, bike repair, for a ski and winter tour coffee shops and places to eat as well, and for a city walk museums and galleries, parks and gardens, coffee shops, places to eat and markets): source (TREK places index or OpenStreetMap), kind, address, website, phone, opening hours as the source has them (a hint, never a promise), the distance straight across to the line (not a detour) and how far along the line; up to 60, the nearest to the line when more match, listed from the start. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too, so a famous square no category names is found as a sight, and the highlights among them carry `highlight: true` and come first. A place OpenStreetMap has no name for comes with an empty name. `unavailable` names the categories no source could be searched for right now, so their places are missing rather than absent. Says when stretches could not be searched, only the first part of a long line was, or more places matched. A tour saved with its points only (`route_pending`) has no line to search along yet. Nothing is added | `trips:read` |
| `search_tour_pois` | Places along the saved line of a hike, run, ride, bike tour, paddle tour, ski and winter tour or city walk within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental, bike repair, for a ski and winter tour coffee shops and places to eat as well, and for a city walk museums and galleries, parks and gardens, coffee shops, places to eat and markets): source (TREK places index or OpenStreetMap), kind, address, website, phone, opening hours as the source has them (a hint, never a promise), the distance straight across to the line (not a detour) and how far along the line; up to 60, the nearest to the line when more match, listed from the start. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too, so a famous square no category names is found as a sight, and the highlights among them carry `highlight: true` and come first. A place OpenStreetMap has no name for comes with an empty name. `unavailable` names the categories no source could be searched for right now, so their places are missing rather than absent. `retry_after_s`, when present beside `unavailable`, is how many seconds until asking again makes sense, because the OpenStreetMap mirrors failed and are only probed now and then. Says when stretches could not be searched, only the first part of a long line was, or more places matched. A tour saved with its points only (`route_pending`) has no line to search along yet. Nothing is added | `trips:read` |
| `list_tour_library` | The saved tours of every trip the user can open, to choose one to take over into another trip: name, type, distance, ascent, descent, planned times and the trip each is in. Narrowed by a part of the name, a type or a trip; the trip being planned can be left out. Up to 100 tours, `truncated` says there were more | `trips:read` |
| `assign_tour_track` | Links an actual track to a saved tour, once the user chose it: another tour of the same trip that came in through the GPX import or from wanderer and was not planned over, for one of the tour's day assignments or for none. A day that already has one gets this one instead; a track belongs to one tour only. The planned line, measures and times of the tour do not change. Needs `place_edit` | `trips:write` |
| `unassign_tour_track` | Unlinks an actual track from a tour again; the track stays in the trip as the import it is and the tour does not change. Needs `place_edit` | `trips:write` |