diff --git a/client/src/components/Tours/poi/TourPoiCard.tsx b/client/src/components/Tours/poi/TourPoiCard.tsx
index ecee31630..5694d524c 100644
--- a/client/src/components/Tours/poi/TourPoiCard.tsx
+++ b/client/src/components/Tours/poi/TourPoiCard.tsx
@@ -1,6 +1,6 @@
import type { TourPoi, WalkTheme } from '@trek/shared';
import { WALK_THEMES } from '@trek/shared';
-import { Clock, ExternalLink, MoveHorizontal, Plus, Route, X } from 'lucide-react';
+import { Clock, ExternalLink, MoveHorizontal, Plus, Route, Star, X } from 'lucide-react';
import { useCallback, useRef } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
@@ -9,7 +9,7 @@ import { formatDistance } from '../../../utils/units';
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
import { useMapCardDismiss } from '../../Map/useMapCardDismiss';
-import { WhiteBadge } from '../../Planner/planParts';
+import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { PillSelect } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
@@ -157,7 +157,22 @@ function TourPoiCard({
{poi.name || kind}
- {poi.name && {kind}
}
+ {(poi.name || poi.highlight) && (
+
+ {poi.name &&
{kind}
}
+ {/* One of the best known places along the route, as the star in the list marks it. */}
+ {poi.highlight && (
+
+ }
+ >
+ {t('tours.poi.highlight')}
+
+
+ )}
+
+ )}
+ {/* One of the best known places along the route: a star before its name. */}
+ {poi.highlight && (
+
+
+
+ )}
{poi.name || t(look.labelKey)}
diff --git a/client/src/components/Tours/poi/tourPoiSearch.test.tsx b/client/src/components/Tours/poi/tourPoiSearch.test.tsx
index 682d5e13b..3b56ff258 100644
--- a/client/src/components/Tours/poi/tourPoiSearch.test.tsx
+++ b/client/src/components/Tours/poi/tourPoiSearch.test.tsx
@@ -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-016
+// FE-TOUR-POI-001 to FE-TOUR-POI-018
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
@@ -288,6 +288,46 @@ describe('TourPoiSearchCard', () => {
});
});
+describe('highlights along a tour', () => {
+ const SQUARE: TourPoi = {
+ ...SPRING,
+ id: 'way:1',
+ name: 'Piazza San Marco',
+ category: 'sights',
+ kind: 'place=square',
+ opening_hours: null,
+ website: null,
+ highlight: true,
+ };
+
+ it('FE-TOUR-POI-017: a highlight carries a star before its name in the list, a place that is none carries none', async () => {
+ search.mockResolvedValue({ ...ANSWER, pois: [SQUARE, SPRING] });
+ render( );
+ fireEvent.click(screen.getByTestId('tour-poi-search'));
+ const square = await screen.findByTestId('tour-poi-way:1');
+ const star = within(square).getByTestId('tour-poi-highlight');
+ expect(star).toHaveAttribute('aria-label', 'Highlight');
+ expect(star).toHaveAttribute('title', 'One of the best-known places here');
+ expect(within(square).getByRole('button', { name: /Highlight.*Piazza San Marco/ })).toBeInTheDocument();
+ expect(within(screen.getByTestId('tour-poi-node:1')).queryByTestId('tour-poi-highlight')).not.toBeInTheDocument();
+ });
+
+ it('FE-TOUR-POI-018: the card over the map says Highlight beside the kind, and only for a highlight', async () => {
+ search.mockResolvedValue({ ...ANSWER, pois: [SQUARE, SPRING] });
+ const planner = { ...searchPlanner(), ...insertionPlanner() } as unknown as TourPlannerController;
+ renderAlong(planner);
+ fireEvent.click(screen.getByTestId('tour-poi-search'));
+ fireEvent.click(within(await screen.findByTestId('tour-poi-way:1')).getByRole('button', { name: /Piazza/ }));
+ const badge = within(screen.getByTestId('tour-poi-overlay')).getByTestId('tour-poi-highlight-badge');
+ expect(badge).toHaveTextContent('Highlight');
+ expect(badge).toHaveAttribute('title', 'One of the best-known places here');
+ expect(screen.getByTestId('tour-poi-overlay')).toHaveTextContent('Sights');
+
+ fireEvent.click(within(screen.getByTestId('tour-poi-node:1')).getByRole('button', { name: /Spring/ }));
+ expect(screen.queryByTestId('tour-poi-highlight-badge')).not.toBeInTheDocument();
+ });
+});
+
describe('a city walk along its places (CW-05)', () => {
const CAFE: TourPoi = {
...SPRING,
diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts
index df36bded5..1ad9237bc 100644
--- a/server/src/nest/maps/maps.service.ts
+++ b/server/src/nest/maps/maps.service.ts
@@ -30,6 +30,7 @@ import {
trekPlacesArea,
trekPlacesNearby,
toPlaceRecord,
+ type TrekHighlight,
type TrekPlace,
} from './trek-places.client';
import { InjectRepository } from '@mikro-orm/nestjs';
@@ -252,6 +253,15 @@ export class MapsService {
return this.along.search({ categories, line, corridorM, lang, limit }, this.trekPlacesEnabled());
}
+ /**
+ * The best known places near a line, from the index's OpenStreetMap layer in
+ * the same circles as {@link poisAlong}. None when the index is switched off
+ * or cannot be asked: highlights add to a search, they never fail it.
+ */
+ highlightsAlong(line: { lat: number; lng: number }[], corridorM: number, lang?: string): Promise {
+ return this.along.highlights(line, corridorM, lang, this.trekPlacesEnabled());
+ }
+
// ── Keys and the keyed provider (resolved by PlacesProviderSelector) ─────
/** The Places credential for this request and where it came from (#1939). */
diff --git a/server/src/nest/maps/pois-along.resolver.ts b/server/src/nest/maps/pois-along.resolver.ts
index ec1a70ed4..5c70f23ae 100644
--- a/server/src/nest/maps/pois-along.resolver.ts
+++ b/server/src/nest/maps/pois-along.resolver.ts
@@ -28,13 +28,16 @@ import {
} from './maps-index.helpers';
import type { OverpassPoi } from './maps.helpers';
import { OsmClient, OverpassCoolingDown } from './providers/osm.client';
-import { trekPlacesNearby } from './trek-places.client';
+import { trekPlacesHighlights, trekPlacesNearby, type TrekHighlight, type TrekHighlights } from './trek-places.client';
import { Injectable } from '@nestjs/common';
import type { LatLng } from '@trek/shared/roadtrip';
/** How many index requests are in flight at once, so one search never floods the service. */
const PARALLEL_INDEX_REQUESTS = 8;
+/** The most well known places one circle is asked for: the best known come first, so a few are plenty. */
+const HIGHLIGHTS_PER_CIRCLE = 20;
+
/** How many stretches one source asks Overpass for at once, so the public mirrors are not flooded. */
const PARALLEL_OVERPASS_STRETCHES = 3;
@@ -148,6 +151,48 @@ export class PoisAlongResolver {
};
}
+ /**
+ * The best known places near a line, from the index's OpenStreetMap layer in
+ * the same circles the kinds are asked in, each place once. Highlights only
+ * add to a search: an index switched off, down or without ranks answers
+ * none, logged once, and never fails the search they were asked beside.
+ */
+ async highlights(
+ line: LatLng[],
+ corridorM: number,
+ lang: string | undefined,
+ indexEnabled: boolean,
+ ): Promise {
+ if (!indexEnabled) return [];
+ const plan = alongPlan(line, corridorM);
+ const perCircle = { limit: HIGHLIGHTS_PER_CIRCLE, lang };
+ const batches = Array.from({ length: Math.ceil(plan.beads.length / PARALLEL_INDEX_REQUESTS) }, (_, index) =>
+ plan.beads.slice(index * PARALLEL_INDEX_REQUESTS, (index + 1) * PARALLEL_INDEX_REQUESTS),
+ );
+ // A few circles at a time, each batch after the last, as the kinds are asked.
+ const settled = await batches.reduce[]>>(
+ async (done, batch) => [
+ ...(await done),
+ ...(await Promise.allSettled(
+ batch.map((bead) => trekPlacesHighlights(bead.lat, bead.lng, { ...perCircle, radius: bead.radiusM })),
+ )),
+ ],
+ Promise.resolve([]),
+ );
+ const found = new Map();
+ const failure = settled.find((entry): entry is PromiseRejectedResult => entry.status === 'rejected');
+ for (const entry of settled) {
+ if (entry.status === 'fulfilled') {
+ for (const row of entry.value.highlights) {
+ const key = `${row.osmType}:${row.osmId}`;
+ if (!found.has(key)) found.set(key, row);
+ }
+ }
+ }
+ if (failure) logWarn(`[Maps] TREK places highlights along a line: ${reason(failure.reason)}`);
+ return [...found.values()];
+ }
+
/** The index kinds in every circle, a few requests at a time; a failed request leaves the others. */
private async askIndex(kinds: string[], plan: AlongPlan, limit: number): Promise {
// No kinds make no plan, and so no request.
diff --git a/server/src/nest/maps/trek-places.client.ts b/server/src/nest/maps/trek-places.client.ts
index eb2b4028a..aede3319e 100644
--- a/server/src/nest/maps/trek-places.client.ts
+++ b/server/src/nest/maps/trek-places.client.ts
@@ -15,6 +15,8 @@ import { readEnv, getAppUrl } from '../../app-config';
import { toApiLang } from './maps.helpers';
import { normalizePlaceWebsite } from '@trek/shared';
+import { z } from 'zod';
+
/** The public instance. An operator may point at their own copy instead. */
export const DEFAULT_TREK_PLACES_URL = 'https://places.liketrek.com';
@@ -452,6 +454,93 @@ export async function trekPlacesNearby(
return Array.isArray(body.results) ? body.results : [];
}
+/**
+ * The bounds of one /v1/highlights request, as the service takes them: a
+ * request outside them is refused with a 422, so one is clamped to fit before
+ * it leaves, and a coordinate that is no coordinate is not asked at all.
+ */
+const HIGHLIGHTS_MIN_RADIUS_M = 10;
+const HIGHLIGHTS_MAX_RADIUS_M = 20_000;
+const HIGHLIGHTS_DEFAULT_RADIUS_M = 1000;
+const HIGHLIGHTS_MAX_LIMIT = 100;
+const HIGHLIGHTS_DEFAULT_LIMIT = 30;
+
+/**
+ * One well known place of the service's OpenStreetMap layer: a row with a
+ * Wikidata item that QRank ranks, `qrank` its page views. `name` is in the
+ * language asked where the layer has it, else the local one.
+ */
+const trekHighlightSchema = z.object({
+ osmType: z.enum(['node', 'way', 'relation']),
+ osmId: z.number().int().positive(),
+ name: z.string().trim().min(1),
+ lat: z.number().min(-90).max(90),
+ lng: z.number().min(-180).max(180),
+ category: z.string().nullable(),
+ osmTag: z.string(),
+ wikidata: z.string().regex(/^Q\d+$/),
+ qrank: z.number().int().nonnegative(),
+ distanceMetres: z.number().nonnegative(),
+});
+export type TrekHighlight = z.infer;
+
+export interface TrekHighlights {
+ highlights: TrekHighlight[];
+ /** The QRank release the ranks are from, or null when the service does not say. */
+ qrankRelease: string | null;
+}
+
+/** A whole number within the bounds; anything that is no number takes the lower one. */
+const clampedInt = (value: number, min: number, max: number) =>
+ Number.isFinite(value) ? Math.min(max, Math.max(min, Math.round(value))) : min;
+
+/**
+ * The service's answer as rows TREK can trust: a row of another shape is
+ * dropped on its own rather than failing the others, since the layer is read
+ * beside the rest of a search and a broken row should cost that row only.
+ */
+function readHighlights(body: unknown): TrekHighlights {
+ const answer = z
+ .object({ highlights: z.array(z.unknown()).catch([]), qrankRelease: z.string().nullable().catch(null) })
+ .catch({ highlights: [], qrankRelease: null })
+ .parse(body);
+ const highlights = answer.highlights.flatMap((row) => {
+ const parsed = trekHighlightSchema.safeParse(row);
+ return parsed.success ? [parsed.data] : [];
+ });
+ return { highlights, qrankRelease: answer.qrankRelease };
+}
+
+/**
+ * The best known places around a coordinate, by QRank and then nearest. An
+ * older layer without ranks answers an empty list, never an error; a service
+ * that is down throws like every other call here, and the caller goes on
+ * without highlights.
+ */
+export async function trekPlacesHighlights(
+ lat: number,
+ lng: number,
+ opts: { radius?: number; limit?: number; lang?: string } = {},
+): Promise {
+ if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) {
+ return { highlights: [], qrankRelease: null };
+ }
+ try {
+ const body = await getJson('/v1/highlights', {
+ lat,
+ lng,
+ radius: clampedInt(opts.radius ?? HIGHLIGHTS_DEFAULT_RADIUS_M, HIGHLIGHTS_MIN_RADIUS_M, HIGHLIGHTS_MAX_RADIUS_M),
+ limit: clampedInt(opts.limit ?? HIGHLIGHTS_DEFAULT_LIMIT, 1, HIGHLIGHTS_MAX_LIMIT),
+ lang: serviceLang(opts.lang),
+ });
+ return readHighlights(body);
+ } catch (err) {
+ // A service older than the endpoint knows no highlights, as an old layer knows no ranks.
+ if ((err as { status?: number }).status === 404) return { highlights: [], qrankRelease: null };
+ throw err;
+ }
+}
+
export interface TrekPlacesArea {
count: number;
truncated: boolean;
diff --git a/server/src/nest/tours/tour-poi-search.service.ts b/server/src/nest/tours/tour-poi-search.service.ts
index 89d58e3db..7350ca137 100644
--- a/server/src/nest/tours/tour-poi-search.service.ts
+++ b/server/src/nest/tours/tour-poi-search.service.ts
@@ -11,6 +11,7 @@ import {
plannableTourTypeSchema,
TOUR_POI_LIMIT,
TOUR_POI_MAX_LINE_POINTS,
+ tourHighlightIds,
type TourPoi,
type TourPoiCategory,
type TourPoiSearchRequest,
@@ -46,13 +47,144 @@ const PER_KIND_LIMIT = 30;
/** Within this many metres, two places of one name are one place mapped twice. */
const SAME_PLACE_M = 60;
-type MapsPoi = Awaited>['pois'][number];
+/**
+ * Within this many metres, a highlight of the very same name is the place a
+ * row already has: a square's outline and the point on it lie further apart
+ * than two points of one place do.
+ */
+const SAME_NAME_M = 500;
-function withinSamePlace(a: Pick, b: { lat: number; lng: number }): boolean {
+type MapsPoi = Awaited>['pois'][number];
+type MapsHighlight = Awaited>[number];
+
+function metresApart(a: Pick, b: { lat: number; lng: number }): number {
const metresPerDegree = 111_320;
const dLat = (a.lat - b.lat) * metresPerDegree;
const dLng = (a.lng - b.lng) * metresPerDegree * Math.cos((a.lat * Math.PI) / 180);
- return Math.hypot(dLat, dLng) <= SAME_PLACE_M;
+ return Math.hypot(dLat, dLng);
+}
+
+function withinSamePlace(a: Pick, b: { lat: number; lng: number }): boolean {
+ return metresApart(a, b) <= SAME_PLACE_M;
+}
+
+/** A name as compared: without case, accents or punctuation. */
+function nameKey(name: string): string {
+ return name
+ .normalize('NFD')
+ .replace(/\p{M}/gu, '')
+ .toLocaleLowerCase()
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
+ .trim();
+}
+
+/**
+ * Every word of one name is in the other, as "Basilica San Marco" is in
+ * "Basilica di San Marco", or they share at least two words and half of each.
+ * Whole words only: "Dom" is not "Domplatz", and one shared "Palazzo" does not
+ * make "Palazzo Ducale" the "Palazzo Reale".
+ */
+function similarNames(a: string, b: string): boolean {
+ if (!a || !b) return false;
+ const words = new Set(a.split(' '));
+ const others = new Set(b.split(' '));
+ const shared = [...others].filter((word) => words.has(word)).length;
+ if (shared === Math.min(words.size, others.size)) return true;
+ return shared >= 2 && shared * 2 >= Math.max(words.size, others.size);
+}
+
+/**
+ * The row a highlight is already in: the same OpenStreetMap object, the same
+ * name nearby, or a like name in one spot.
+ */
+function rowOfHighlight(places: readonly TourPoi[], highlight: MapsHighlight, id: string): TourPoi | undefined {
+ const name = nameKey(highlight.name);
+ return places.find((place) => {
+ if (place.id === id) return true;
+ const other = nameKey(place.name);
+ const apart = metresApart(place, highlight);
+ return (!!name && other === name && apart <= SAME_NAME_M) || (apart <= SAME_PLACE_M && similarNames(name, other));
+ });
+}
+
+/**
+ * The kinds of the index a highlight may come in as a sight of its own. A
+ * station, a hotel or a restaurant can be famous too, but it is no sight: it
+ * still ranks a row it matches, and is never added.
+ */
+const SIGHT_INDEX_CATEGORIES: ReadonlySet = new Set(['sehenswuerdigkeit', 'kultur']);
+
+/**
+ * The places found with the best known places near the line merged in: a
+ * highlight a row already has ranks that row, and a sight no row has is added
+ * when the rule marks it (Piazza San Marco is a square, which no kind asks for).
+ * A ranked place the rule does not mark is not worth a row of its own. The
+ * ranks are kept beside the rows, by id, for the highlight rule.
+ */
+function withHighlights(
+ places: readonly TourPoi[],
+ highlights: readonly MapsHighlight[],
+ corridorM: number,
+ line: Array<[number, number]>,
+): { places: TourPoi[]; ranks: Map } {
+ const merged = [...places];
+ const candidates: TourPoi[] = [];
+ const ranks = new Map();
+ for (const highlight of [...highlights].sort((a, b) => b.qrank - a.qrank)) {
+ const near = nearnessToLine({ lat: highlight.lat, lng: highlight.lng }, line);
+ if (!near || near.distanceM > corridorM) continue;
+ const id = `${highlight.osmType}:${highlight.osmId}`;
+ const row = rowOfHighlight([...merged, ...candidates], highlight, id);
+ if (row) {
+ ranks.set(row.id, Math.max(ranks.get(row.id) ?? 0, highlight.qrank));
+ continue;
+ }
+ if (!highlight.category || !SIGHT_INDEX_CATEGORIES.has(highlight.category)) continue;
+ candidates.push({
+ id,
+ name: highlight.name,
+ lat: highlight.lat,
+ lng: highlight.lng,
+ category: 'sights',
+ kind: highlight.osmTag,
+ source: 'openstreetmap',
+ address: null,
+ website: null,
+ phone: null,
+ opening_hours: null,
+ distance_m: Math.round(near.distanceM),
+ along_m: Math.round(near.alongM),
+ });
+ ranks.set(id, highlight.qrank);
+ }
+ const marked = new Set(tourHighlightIds([...ranks].map(([id, qrank]) => ({ id, qrank }))));
+ for (const candidate of candidates) {
+ if (marked.has(candidate.id)) merged.push(candidate);
+ else ranks.delete(candidate.id);
+ }
+ return { places: merged, ranks };
+}
+
+/**
+ * The answer's rows: the highlights first, best known first, then the nearest
+ * of the rest to the line, in the order the tour meets them. A highlight is
+ * never cut for a nearer place.
+ */
+function answerRows(places: readonly TourPoi[], ranks: ReadonlyMap): TourPoi[] {
+ const marked = new Set(tourHighlightIds([...ranks].map(([id, qrank]) => ({ id, qrank }))));
+ const rank = (place: TourPoi) => ranks.get(place.id) ?? 0;
+ const highlights = places
+ .filter((place) => marked.has(place.id))
+ .sort((a, b) => rank(b) - rank(a))
+ .slice(0, TOUR_POI_LIMIT)
+ .map((place) => ({ ...place, highlight: true }));
+ // The nearest to the line when there are too many, then in the order the tour meets them.
+ const rest = places
+ .filter((place) => !marked.has(place.id))
+ .sort((a, b) => a.distance_m - b.distance_m)
+ .slice(0, TOUR_POI_LIMIT - highlights.length)
+ .sort((a, b) => a.along_m - b.along_m || a.distance_m - b.distance_m);
+ return [...highlights, ...rest];
}
function tourCategoryOf(mapsCategory: string, asked: readonly TourPoiCategory[]): TourPoiCategory | undefined {
@@ -104,7 +236,8 @@ function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<
* answer carries, listed from the start. Nothing is written. A kind no source
* could be asked for is named as unavailable, so a failed source never reads
* as nothing found; the distance is measured straight across to the line,
- * never a way someone walked or paddled.
+ * never a way someone walked or paddled. A search for sights also asks for the
+ * best known places near the line and lists its highlights first.
*/
@Injectable()
export class TourPoiSearchService {
@@ -117,20 +250,17 @@ export class TourPoiSearchService {
async search(body: TourPoiSearchRequest): Promise {
// The contract's tuples, spelled out as pairs once for the measures below.
const line = body.line.map((point): [number, number] => [Number(point[0]), Number(point[1])]);
+ const points = line.map(([lat, lng]) => ({ lat, lng }));
const asked = [...new Set(body.categories.flatMap((category) => MAPS_CATEGORIES[category]))];
- const along = await this.maps.poisAlong(
- asked,
- line.map(([lat, lng]) => ({ lat, lng })),
- body.corridor_m,
- body.lang,
- PER_KIND_LIMIT,
- );
- const places = placesWithin(along.pois, body, line);
- // The nearest to the line when there are too many, then in the order the tour meets them.
- const nearest = [...places].sort((a, b) => a.distance_m - b.distance_m).slice(0, TOUR_POI_LIMIT);
+ // The best known places are what a search for sights is for, on any tour.
+ const [along, highlights] = await Promise.all([
+ this.maps.poisAlong(asked, points, body.corridor_m, body.lang, PER_KIND_LIMIT),
+ body.categories.includes('sights') ? this.highlightsAlong(points, body) : [],
+ ]);
+ const found = withHighlights(placesWithin(along.pois, body, line), highlights, body.corridor_m, line);
return {
- pois: nearest.sort((a, b) => a.along_m - b.along_m || a.distance_m - b.distance_m),
- truncated: along.truncated || places.length > TOUR_POI_LIMIT,
+ pois: answerRows(found.places, found.ranks),
+ truncated: along.truncated || found.places.length > TOUR_POI_LIMIT,
partial: along.partial,
capped: along.capped,
unavailable: body.categories.filter((category) =>
@@ -139,6 +269,18 @@ export class TourPoiSearchService {
};
}
+ /** The best known places near the line, or none when they cannot be had: they add to a search, never fail it. */
+ private async highlightsAlong(
+ points: Array<{ lat: number; lng: number }>,
+ body: TourPoiSearchRequest,
+ ): Promise {
+ try {
+ return await this.maps.highlightsAlong(points, body.corridor_m, body.lang);
+ } catch {
+ return [];
+ }
+ }
+
/** The same search along a saved tour's line, for a client that has no draft (MCP). */
async searchSavedTour(
tripId: number,
diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts
index 22826ffbc..6af59d02a 100644
--- a/server/src/nest/tours/tours.mcp.ts
+++ b/server/src/nest/tours/tours.mcp.ts
@@ -190,7 +190,7 @@ export class ToursMcp {
@Tool({
name: 'search_tour_pois',
description:
- 'Search places along the saved line of a tour: what a hike, a bike tour, a paddle tour or a city walk stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops, and for a city walk museum for museums and galleries, park for parks and gardens, cafe, restaurant and market), within corridor_m metres of the line. Answers each place with its source (TREK places index or OpenStreetMap), kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line); up to 60, the nearest to the line when more match, listed from the start. A place OpenStreetMap has no name for (a tap, a bench) comes with an empty name. unavailable lists the categories no source could be searched for right now: their places are missing, not absent, so ask again later rather than reporting none. partial says some stretches could not be searched, capped that only the first part of a long line was, truncated that more places matched. A tour saved with its points only (route_pending) has no line to search along yet. Nothing is added to the tour or the trip.',
+ 'Search places along the saved line of a tour: what a hike, a bike tour, a paddle tour or a city walk stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops, and for a city walk museum for museums and galleries, park for parks and gardens, cafe, restaurant and market), within corridor_m metres of the line. Answers each place with its source (TREK places index or OpenStreetMap), kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start 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: a square or landmark no category names is added as a sight, and the highlights among them carry highlight: true and come first, the best known first. A place OpenStreetMap has no name for (a tap, a bench) comes with an empty name. unavailable lists the categories no source could be searched for right now: their places are missing, not absent, so ask again later rather than reporting none. partial says some stretches could not be searched, capped that only the first part of a long line was, truncated that more places matched. A tour saved with its points only (route_pending) has no line to search along yet. Nothing is added to the tour or the trip.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
diff --git a/server/tests/unit/nest/maps.pois-along.test.ts b/server/tests/unit/nest/maps.pois-along.test.ts
index c6c6143dc..8c57a3c38 100644
--- a/server/tests/unit/nest/maps.pois-along.test.ts
+++ b/server/tests/unit/nest/maps.pois-along.test.ts
@@ -20,8 +20,15 @@ import { buildMapsParts } from '../../helpers/maps-service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-const { mockNearby, logWarn } = vi.hoisted(() => ({
+const { mockNearby, mockHighlights, logWarn } = vi.hoisted(() => ({
logWarn: vi.fn(),
+ mockHighlights: vi.fn(
+ async (
+ _lat: number,
+ _lng: number,
+ _opts?: { radius?: number; limit?: number; lang?: string },
+ ): Promise<{ highlights: unknown[]; qrankRelease: string | null }> => ({ highlights: [], qrankRelease: null }),
+ ),
mockNearby: vi.fn(
async (
_lat: number,
@@ -38,6 +45,7 @@ vi.mock('../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
...(await importOriginal()),
trekPlacesNearby: mockNearby,
+ trekPlacesHighlights: mockHighlights,
}));
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
@@ -141,6 +149,8 @@ function inFlight(overpass: ReturnType['overpass']) {
beforeEach(() => {
mockNearby.mockReset();
mockNearby.mockResolvedValue([]);
+ mockHighlights.mockReset();
+ mockHighlights.mockResolvedValue({ highlights: [], qrankRelease: null });
logWarn.mockClear();
});
@@ -523,3 +533,51 @@ describe('MapsService.poisAlong', () => {
}
});
});
+
+describe('MapsService.highlightsAlong', () => {
+ /** A well known place of the index's OpenStreetMap layer. */
+ const famous = (osmType: string, osmId: number, qrank: number) => ({
+ osmType,
+ osmId,
+ name: `${osmType} ${osmId}`,
+ lat: 47.01,
+ lng: 11,
+ category: 'sehenswuerdigkeit',
+ osmTag: 'place=square',
+ wikidata: 'Q1',
+ qrank,
+ distanceMetres: 10,
+ });
+
+ it('MAPS-ALONG-030: asks the best known places in the circles the kinds are asked in, each place once', async () => {
+ const { svc } = make();
+ mockHighlights
+ .mockResolvedValueOnce({ highlights: [famous('way', 1, 900), famous('node', 1, 50)], qrankRelease: null })
+ .mockResolvedValueOnce({ highlights: [famous('way', 1, 900), famous('relation', 2, 70)], qrankRelease: null });
+
+ const out = await svc.highlightsAlong(LINE, 500, 'de');
+
+ expect(mockHighlights).toHaveBeenCalledTimes(4);
+ for (const [, , opts] of mockHighlights.mock.calls) expect(opts).toEqual({ radius: 1000, limit: 20, lang: 'de' });
+ expect(out.map((row) => `${row.osmType}:${row.osmId}`)).toEqual(['way:1', 'node:1', 'relation:2']);
+ expect(mockNearby).not.toHaveBeenCalled();
+ });
+
+ it('MAPS-ALONG-031: a circle failing keeps the others, an index down or switched off answers none, never an error', async () => {
+ const { svc } = make();
+ mockHighlights
+ .mockRejectedValueOnce(new Error('TREK Places API 503'))
+ .mockResolvedValue({ highlights: [famous('way', 1, 900)], qrankRelease: null });
+ expect((await svc.highlightsAlong(LINE, 500)).map((row) => row.osmId)).toEqual([1]);
+ expect(mapsWarnings()).toEqual(['[Maps] TREK places highlights along a line: TREK Places API 503']);
+
+ mockHighlights.mockReset();
+ mockHighlights.mockRejectedValue(new Error('timeout'));
+ await expect(svc.highlightsAlong(LINE, 500)).resolves.toEqual([]);
+
+ mockHighlights.mockClear();
+ const off = make(false);
+ await expect(off.svc.highlightsAlong(LINE, 500)).resolves.toEqual([]);
+ expect(mockHighlights).not.toHaveBeenCalled();
+ });
+});
diff --git a/server/tests/unit/nest/tour-poi-search.service.test.ts b/server/tests/unit/nest/tour-poi-search.service.test.ts
index cb82e0753..7ba4ea14b 100644
--- a/server/tests/unit/nest/tour-poi-search.service.test.ts
+++ b/server/tests/unit/nest/tour-poi-search.service.test.ts
@@ -21,6 +21,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
const db = createSnapshotTestDb();
const poisAlong = vi.fn();
+const highlightsAlong = vi.fn();
let t: TestModule;
let service: TourPoiSearchService;
@@ -28,13 +29,15 @@ beforeAll(async () => {
t = await createTestModule({
db,
imports: [ToursModule],
- overrides: [{ provide: MapsService, useValue: { poisAlong } }],
+ overrides: [{ provide: MapsService, useValue: { poisAlong, highlightsAlong } }],
});
service = t.get(TourPoiSearchService);
});
beforeEach(() => {
resetTestDb(db);
poisAlong.mockReset();
+ highlightsAlong.mockReset();
+ highlightsAlong.mockResolvedValue([]);
});
afterAll(async () => {
await t.close();
@@ -68,6 +71,30 @@ function poi(id: string, lat: number, lng: number, category = 'viewpoint', over:
};
}
+/** A well known place of the index's OpenStreetMap layer, as MapsService.highlightsAlong answers it. */
+function famous(
+ osmType: string,
+ osmId: number,
+ name: string,
+ lat: number,
+ lng: number,
+ qrank: number,
+ category: string | null = 'sehenswuerdigkeit',
+) {
+ return {
+ osmType,
+ osmId,
+ name,
+ lat,
+ lng,
+ category,
+ osmTag: 'place=square',
+ wikidata: `Q${osmId}`,
+ qrank,
+ distanceMetres: 0,
+ };
+}
+
const answer = (list: unknown[], over: Record = {}) => ({
pois: list,
failed: [],
@@ -232,6 +259,144 @@ describe('TourPoiSearchService', () => {
]);
});
+ it('TOUR-POI-011: a search for sights asks the best known places along the line and adds a square no kind names', async () => {
+ poisAlong.mockResolvedValue(answer([poi('node:gate', 47.405, 11.1003, 'sights', { name: 'Town gate' })]));
+ highlightsAlong.mockResolvedValue([famous('way', 1, 'Piazza San Marco', 47.41, 11.1004, 900_000)]);
+ const result = await service.search({
+ tour_type: 'walk',
+ categories: ['sights', 'cafe'],
+ corridor_m: 500,
+ line: LINE,
+ lang: 'it',
+ });
+ expect(highlightsAlong).toHaveBeenCalledWith(
+ [
+ { lat: 47.4, lng: 11.1 },
+ { lat: 47.42, lng: 11.1 },
+ ],
+ 500,
+ 'it',
+ );
+ expect(result.pois.map((found) => [found.id, found.highlight])).toEqual([
+ ['way:1', true],
+ ['node:gate', undefined],
+ ]);
+ expect(result.pois[0]).toMatchObject({
+ name: 'Piazza San Marco',
+ category: 'sights',
+ kind: 'place=square',
+ source: 'openstreetmap',
+ address: null,
+ opening_hours: null,
+ });
+ expect(result.pois[0]?.distance_m).toBeGreaterThan(20);
+ expect(result.pois[0]?.distance_m).toBeLessThan(40);
+ expect(result.pois[0]?.along_m).toBeGreaterThan(1050);
+ expect(result.pois[0]?.along_m).toBeLessThan(1180);
+
+ // Without sights nothing of the kind is asked, on a walk or a hike.
+ highlightsAlong.mockClear();
+ await service.search({ tour_type: 'walk', categories: ['cafe'], corridor_m: 500, line: LINE });
+ await service.search({ tour_type: 'hike', categories: ['viewpoint'], corridor_m: 500, line: LINE });
+ expect(highlightsAlong).not.toHaveBeenCalled();
+ });
+
+ it('TOUR-POI-012: a highlight a row already has marks that row, by object, by name or by a like name in one spot, once', async () => {
+ poisAlong.mockResolvedValue(
+ answer([
+ poi('node:basilica', 47.405, 11.1003, 'sights', { name: 'Basilica di San Marco' }),
+ poi('node:museo', 47.41, 11.1003, 'museum_gallery', { name: 'Museo Correr' }),
+ poi('way:7', 47.415, 11.1002, 'sights', { name: 'Campanile' }),
+ poi('node:cafe', 47.402, 11.1003, 'cafe', { name: 'Caffe Florian' }),
+ ]),
+ );
+ highlightsAlong.mockResolvedValue([
+ famous('way', 7, 'Campanile di San Marco', 47.415, 11.1002, 45_000),
+ famous('node', 2, 'Piazza San Marco', 47.4102, 11.1004, 80_000),
+ famous('way', 1, 'Piazza San Marco', 47.4101, 11.1004, 900_000),
+ famous('node', 3, 'Basilica San Marco', 47.4052, 11.1003, 500_000),
+ famous('node', 4, 'MUSEO CORRER', 47.4127, 11.1003, 60_000),
+ // Far outside the corridor: not along this walk, however well known.
+ famous('way', 99, 'Rialto', 47.41, 11.2, 1_000_000),
+ ]);
+ const result = await service.search({
+ tour_type: 'walk',
+ categories: ['sights', 'museum', 'cafe'],
+ corridor_m: 500,
+ line: LINE,
+ });
+ expect(result.pois.map((found) => [found.id, found.highlight ?? false])).toEqual([
+ ['way:1', true],
+ ['node:basilica', true],
+ ['node:museo', true],
+ ['way:7', true],
+ ['node:cafe', false],
+ ]);
+ // The rows a highlight marks keep what their own source said of them.
+ expect(result.pois[2]).toMatchObject({ name: 'Museo Correr', category: 'museum', kind: 'tourism=museum_gallery' });
+ });
+
+ it('TOUR-POI-013: with fewer than three famous places the best known three are highlights, a lesser one gets no row', async () => {
+ poisAlong.mockResolvedValue(answer([]));
+ highlightsAlong.mockResolvedValue([
+ famous('node', 1, 'Old bridge', 47.402, 11.1002, 4_000),
+ famous('node', 2, 'Town hall', 47.404, 11.1002, 6_000),
+ famous('node', 3, 'Cathedral', 47.406, 11.1002, 30_000),
+ famous('node', 4, 'Castle', 47.408, 11.1002, 8_000),
+ ]);
+ const result = await service.search({ tour_type: 'hike', categories: ['sights'], corridor_m: 500, line: LINE });
+ expect(result.pois.map((found) => [found.name, found.highlight ?? false])).toEqual([
+ ['Cathedral', true],
+ ['Castle', true],
+ ['Town hall', true],
+ ]);
+ });
+
+ it('TOUR-POI-015: a famous station or hotel ranks a row it matches but is never added as a sight', async () => {
+ poisAlong.mockResolvedValue(answer([poi('node:cafe', 47.402, 11.1003, 'cafe', { name: 'Caffe Florian' })]));
+ highlightsAlong.mockResolvedValue([
+ famous('node', 1, 'Venezia Santa Lucia', 47.404, 11.1002, 300_000, 'verkehr'),
+ famous('node', 2, 'Hotel Danieli', 47.406, 11.1002, 90_000, 'uebernachtung'),
+ famous('node', 3, 'Mystery', 47.408, 11.1002, 90_000, null),
+ famous('node', 4, 'Caffe Florian', 47.402, 11.1003, 70_000, 'gastronomie'),
+ ]);
+ const result = await service.search({
+ tour_type: 'walk',
+ categories: ['sights', 'cafe'],
+ corridor_m: 500,
+ line: LINE,
+ });
+ expect(result.pois.map((found) => [found.id, found.highlight ?? false])).toEqual([['node:cafe', true]]);
+ });
+
+ it('TOUR-POI-016: names are matched by whole words, so a cathedral does not swallow its square or a like-named palace', async () => {
+ poisAlong.mockResolvedValue(
+ answer([
+ poi('node:dom', 47.405, 11.1003, 'sights', { name: 'Dom' }),
+ poi('node:reale', 47.41, 11.1003, 'sights', { name: 'Palazzo Reale' }),
+ ]),
+ );
+ highlightsAlong.mockResolvedValue([
+ famous('way', 1, 'Domplatz', 47.4052, 11.1003, 120_000),
+ famous('way', 2, 'Palazzo Ducale', 47.4101, 11.1003, 380_000),
+ ]);
+ const result = await service.search({ tour_type: 'walk', categories: ['sights'], corridor_m: 500, line: LINE });
+ expect(result.pois.map((found) => [found.id, found.highlight ?? false])).toEqual([
+ ['way:2', true],
+ ['way:1', true],
+ ['node:dom', false],
+ ['node:reale', false],
+ ]);
+ });
+
+ it('TOUR-POI-014: an index that cannot give highlights leaves the search as it was', async () => {
+ poisAlong.mockResolvedValue(answer([poi('node:gate', 47.405, 11.1003, 'sights', { name: 'Town gate' })]));
+ highlightsAlong.mockRejectedValue(new Error('TREK Places API 503'));
+ const result = await service.search({ tour_type: 'walk', categories: ['sights'], corridor_m: 500, line: LINE });
+ expect(result.pois.map((found) => [found.id, found.highlight])).toEqual([['node:gate', undefined]]);
+ expect(result).toMatchObject({ truncated: false, partial: false, unavailable: [] });
+ });
+
it('TOUR-POI-005: searches along a saved tour for MCP, and a tour without a line is not found', async () => {
const { user } = createUser(db);
const trip = createTrip(db, user.id);
diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts
index 0c5dcc3b9..fc46e9bcc 100644
--- a/server/tests/unit/nest/tours.mcp.test.ts
+++ b/server/tests/unit/nest/tours.mcp.test.ts
@@ -347,6 +347,7 @@ describe('ToursMcp', () => {
}
expect(description(ToursMcp, 'getCityWalkProgress')).toMatch(/key .*opening hours/);
expect(description(ToursMcp, 'searchTourPois')).toContain('route_pending');
+ expect(description(ToursMcp, 'searchTourPois')).toMatch(/best known places.*highlight: true/);
expect(description(TourLibraryMcp, 'takeOverTour')).toContain('protection');
for (const method of ['deletePlace', 'bulkDeletePlaces']) {
expect(description(PlacesMcp, method)).toContain(
diff --git a/server/tests/unit/nest/trek-places.client.test.ts b/server/tests/unit/nest/trek-places.client.test.ts
index 237ed2ca2..b9505988a 100644
--- a/server/tests/unit/nest/trek-places.client.test.ts
+++ b/server/tests/unit/nest/trek-places.client.test.ts
@@ -13,6 +13,7 @@ import {
trekPlacesArea,
trekPlacesBaseUrl,
trekPlacesById,
+ trekPlacesHighlights,
trekPlacesNearby,
trekPlacesSearch,
resetTrekPlacesBreaker,
@@ -601,6 +602,78 @@ describe('trekPlacesNearby', () => {
});
});
+describe('trekPlacesHighlights', () => {
+ const SQUARE = {
+ osmType: 'way',
+ osmId: 4_567,
+ name: 'Piazza San Marco',
+ lat: 45.4341,
+ lng: 12.3388,
+ category: 'sehenswuerdigkeit',
+ osmTag: 'place=square',
+ wikidata: 'Q190092',
+ qrank: 912_345,
+ distanceMetres: 42,
+ };
+
+ it('TREK-PLACES-HL-01: asks /v1/highlights around a point in the reader language and reads its rows', async () => {
+ stubFetch({ highlights: [SQUARE], qrankRelease: '2026-10-01' });
+ const out = await trekPlacesHighlights(45.434, 12.338, { radius: 812.6, limit: 12, lang: 'de' });
+ const url = new URL(calls[0].url);
+ expect(url.pathname).toBe('/v1/highlights');
+ expect(url.searchParams.get('lat')).toBe('45.434');
+ expect(url.searchParams.get('lng')).toBe('12.338');
+ expect(url.searchParams.get('radius')).toBe('813');
+ expect(url.searchParams.get('limit')).toBe('12');
+ expect(url.searchParams.get('lang')).toBe('de');
+ expect(out).toEqual({ highlights: [SQUARE], qrankRelease: '2026-10-01' });
+ });
+
+ it('TREK-PLACES-HL-02: keeps every request inside the bounds the service takes, and asks nothing for no coordinate', async () => {
+ stubFetch({ highlights: [] });
+ await trekPlacesHighlights(45.4, 12.3);
+ await trekPlacesHighlights(45.4, 12.3, { radius: 1, limit: 0 });
+ await trekPlacesHighlights(45.4, 12.3, { radius: 90_000, limit: 500 });
+ await trekPlacesHighlights(45.4, 12.3, { radius: Number.NaN, limit: Number.POSITIVE_INFINITY });
+ const asked = calls.map((call) => new URL(call.url).searchParams);
+ expect(asked.map((params) => [params.get('radius'), params.get('limit')])).toEqual([
+ ['1000', '30'],
+ ['10', '1'],
+ ['20000', '100'],
+ ['10', '1'],
+ ]);
+ expect(asked[0]?.has('lang')).toBe(false);
+ await expect(trekPlacesHighlights(Number.NaN, 12.3)).resolves.toEqual({ highlights: [], qrankRelease: null });
+ await expect(trekPlacesHighlights(91, 12.3)).resolves.toEqual({ highlights: [], qrankRelease: null });
+ expect(calls).toHaveLength(4);
+ });
+
+ it('TREK-PLACES-HL-03: drops a row of another shape on its own, and reads an old layer without ranks as none', async () => {
+ stubFetch({
+ highlights: [SQUARE, { ...SQUARE, osmId: 5, wikidata: 'L12' }, { ...SQUARE, osmId: 6, qrank: -1 }, 'row'],
+ qrankRelease: 20261001,
+ });
+ expect(await trekPlacesHighlights(45.4, 12.3)).toEqual({ highlights: [SQUARE], qrankRelease: null });
+ stubFetch({ highlights: { 0: SQUARE } });
+ expect(await trekPlacesHighlights(45.4, 12.3)).toEqual({ highlights: [], qrankRelease: null });
+ stubFetch(null);
+ expect(await trekPlacesHighlights(45.4, 12.3)).toEqual({ highlights: [], qrankRelease: null });
+ });
+
+ it('TREK-PLACES-HL-04: throws on an error status and when the index is switched off, so the caller goes on without', async () => {
+ stubFetch({}, 503);
+ await expect(trekPlacesHighlights(45.4, 12.3)).rejects.toThrow('TREK Places API 503');
+ env.maps.trekPlacesEnabled = false;
+ await expect(trekPlacesHighlights(45.4, 12.3)).rejects.toThrow('switched off');
+ expect(calls).toHaveLength(1);
+ });
+
+ it('TREK-PLACES-HL-05: a service older than the endpoint answers 404, read as no highlights', async () => {
+ stubFetch({}, 404);
+ await expect(trekPlacesHighlights(45.4, 12.3)).resolves.toEqual({ highlights: [], qrankRelease: null });
+ });
+});
+
describe('trekPlacesArea', () => {
it('sends the whole box and caps the rows at 2000', async () => {
stubFetch({ count: 1, truncated: false, results: [PLACE] });
diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts
index 9bda580c6..c577c1398 100644
--- a/shared/src/i18n/ar/tours.ts
+++ b/shared/src/i18n/ar/tours.ts
@@ -601,6 +601,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'الوصول بعد بدء جولة {walk} في {time}',
'tours.poi.categoriesNone': 'لم تُختر أي فئة',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'أبرز المعالم',
+ 'tours.poi.highlightHint': 'من أشهر الأماكن هنا',
'tours.planner.tourType': 'نوع الجولة',
'tours.paddle.boat': 'القارب',
'tours.paddle.boat.kayak': 'كاياك',
diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts
index c1dc98c4b..e45afbeda 100644
--- a/shared/src/i18n/az/tours.ts
+++ b/shared/src/i18n/az/tours.ts
@@ -602,6 +602,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': '{walk} {time} başlayır, çatma isə daha gec olur',
'tours.poi.categoriesNone': 'Kateqoriya seçilməyib',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Diqqətəlayiq',
+ 'tours.poi.highlightHint': 'Buradakı ən tanınmış yerlərdən biri',
'tours.planner.tourType': 'Tur növü',
'tours.paddle.boat': 'Qayıq',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts
index 2f9947266..589721afd 100644
--- a/shared/src/i18n/br/tours.ts
+++ b/shared/src/i18n/br/tours.ts
@@ -607,6 +607,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Chega depois do início de {walk}, às {time}',
'tours.poi.categoriesNone': 'Nenhuma categoria escolhida',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Destaque',
+ 'tours.poi.highlightHint': 'Um dos lugares mais conhecidos daqui',
'tours.planner.tourType': 'Tipo de passeio',
'tours.paddle.boat': 'Embarcação',
'tours.paddle.boat.kayak': 'Caiaque',
diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts
index abcdc884f..77b36b3a9 100644
--- a/shared/src/i18n/ca/tours.ts
+++ b/shared/src/i18n/ca/tours.ts
@@ -608,6 +608,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Arriba després que comenci {walk}, a les {time}',
'tours.poi.categoriesNone': 'Cap categoria triada',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Imprescindible',
+ 'tours.poi.highlightHint': "Un dels llocs més coneguts d'aquí",
'tours.planner.tourType': 'Tipus de ruta',
'tours.paddle.boat': 'Embarcació',
'tours.paddle.boat.kayak': 'Caiac',
diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts
index 6e03dba16..b773fcf0b 100644
--- a/shared/src/i18n/cs/tours.ts
+++ b/shared/src/i18n/cs/tours.ts
@@ -595,6 +595,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Příjezd až po začátku procházky {walk} v {time}',
'tours.poi.categoriesNone': 'Žádná kategorie',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Top místo',
+ 'tours.poi.highlightHint': 'Jedno z nejznámějších míst v okolí',
'tours.planner.tourType': 'Typ túry',
'tours.paddle.boat': 'Loď',
'tours.paddle.boat.kayak': 'Kajak',
diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts
index 9f0e51342..62b49f43f 100644
--- a/shared/src/i18n/de/tours.ts
+++ b/shared/src/i18n/de/tours.ts
@@ -612,6 +612,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Ankunft nach dem Start von {walk} um {time}',
'tours.poi.categoriesNone': 'Keine Kategorie gewählt',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Highlight', // same-as-en
+ 'tours.poi.highlightHint': 'Einer der bekanntesten Orte hier',
'tours.planner.tourType': 'Tourtyp',
'tours.paddle.boat': 'Boot',
'tours.paddle.boat.kayak': 'Kajak',
diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts
index c114179aa..8c2110c47 100644
--- a/shared/src/i18n/en/tours.ts
+++ b/shared/src/i18n/en/tours.ts
@@ -596,6 +596,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Arrives after {walk} starts at {time}',
'tours.poi.categoriesNone': 'No category chosen',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Highlight',
+ 'tours.poi.highlightHint': 'One of the best-known places here',
'tours.planner.tourType': 'Tour type',
'tours.paddle.boat': 'Boat',
'tours.paddle.boat.kayak': 'Kayak',
diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts
index bceb3edd9..a3820cc88 100644
--- a/shared/src/i18n/es/tours.ts
+++ b/shared/src/i18n/es/tours.ts
@@ -607,6 +607,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Llega después de que empiece {walk}, a las {time}',
'tours.poi.categoriesNone': 'Ninguna categoría elegida',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Imprescindible',
+ 'tours.poi.highlightHint': 'Uno de los lugares más conocidos de aquí',
'tours.planner.tourType': 'Tipo de ruta',
'tours.paddle.boat': 'Embarcación',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts
index d2777b41b..9727f7604 100644
--- a/shared/src/i18n/et/tours.ts
+++ b/shared/src/i18n/et/tours.ts
@@ -598,6 +598,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Saabub pärast seda, kui {walk} algab kell {time}',
'tours.poi.categoriesNone': 'Kategooriat pole valitud',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Tippkoht',
+ 'tours.poi.highlightHint': 'Üks siinseid tuntuimaid paiku',
'tours.planner.tourType': 'Matka tüüp',
'tours.paddle.boat': 'Paat',
'tours.paddle.boat.kayak': 'Süst',
diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts
index 30a1fe803..6ad5ac854 100644
--- a/shared/src/i18n/fr/tours.ts
+++ b/shared/src/i18n/fr/tours.ts
@@ -615,6 +615,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Arrivée après le début de {walk} à {time}',
'tours.poi.categoriesNone': 'Aucune catégorie choisie',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Incontournable',
+ 'tours.poi.highlightHint': 'L’un des lieux les plus connus ici',
'tours.planner.tourType': 'Type de sortie',
'tours.paddle.boat': 'Embarcation',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts
index 8a9ba7890..afdfae963 100644
--- a/shared/src/i18n/gr/tours.ts
+++ b/shared/src/i18n/gr/tours.ts
@@ -618,6 +618,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Άφιξη μετά την έναρξη της βόλτας {walk} στις {time}',
'tours.poi.categoriesNone': 'Δεν επιλέχθηκε κατηγορία',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Κορυφαίο',
+ 'tours.poi.highlightHint': 'Ένα από τα πιο γνωστά μέρη εδώ',
'tours.planner.tourType': 'Τύπος διαδρομής',
'tours.paddle.boat': 'Σκάφος',
'tours.paddle.boat.kayak': 'Καγιάκ',
diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts
index 98f09281b..48555c3a2 100644
--- a/shared/src/i18n/hu/tours.ts
+++ b/shared/src/i18n/hu/tours.ts
@@ -608,6 +608,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Érkezés a séta kezdete után: {walk}, {time}',
'tours.poi.categoriesNone': 'Nincs kategória kiválasztva',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Kiemelt',
+ 'tours.poi.highlightHint': 'Az itteni legismertebb helyek egyike',
'tours.planner.tourType': 'Túratípus',
'tours.paddle.boat': 'Hajó',
'tours.paddle.boat.kayak': 'Kajak',
diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts
index 67aca3c3c..5b9c1c22c 100644
--- a/shared/src/i18n/id/tours.ts
+++ b/shared/src/i18n/id/tours.ts
@@ -600,6 +600,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Tiba setelah {walk} dimulai pukul {time}',
'tours.poi.categoriesNone': 'Belum ada kategori',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Sorotan',
+ 'tours.poi.highlightHint': 'Salah satu tempat paling terkenal di sini',
'tours.planner.tourType': 'Jenis tur',
'tours.paddle.boat': 'Perahu',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts
index ccef7ba0a..1d29ef201 100644
--- a/shared/src/i18n/it/tours.ts
+++ b/shared/src/i18n/it/tours.ts
@@ -609,6 +609,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': "Arrivo dopo l'inizio di {walk} alle {time}",
'tours.poi.categoriesNone': 'Nessuna categoria scelta',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Da non perdere',
+ 'tours.poi.highlightHint': 'Uno dei luoghi più famosi di qui',
'tours.planner.tourType': 'Tipo di tour',
'tours.paddle.boat': 'Imbarcazione',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts
index eeba6b001..9c9535d5f 100644
--- a/shared/src/i18n/ja/tours.ts
+++ b/shared/src/i18n/ja/tours.ts
@@ -587,6 +587,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': '{walk} の開始({time})より後に到着',
'tours.poi.categoriesNone': 'カテゴリー未選択',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': '注目スポット',
+ 'tours.poi.highlightHint': 'このあたりで特に有名な場所のひとつ',
'tours.planner.tourType': 'ツアーの種類',
'tours.paddle.boat': 'ボート',
'tours.paddle.boat.kayak': 'カヤック',
diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts
index f8c26a0d0..c63adfcd7 100644
--- a/shared/src/i18n/ko/tours.ts
+++ b/shared/src/i18n/ko/tours.ts
@@ -584,6 +584,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': '{walk} 시작({time}) 이후 도착',
'tours.poi.categoriesNone': '선택한 카테고리 없음',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': '하이라이트',
+ 'tours.poi.highlightHint': '이곳에서 가장 잘 알려진 장소 중 하나',
'tours.planner.tourType': '투어 유형',
'tours.paddle.boat': '보트',
'tours.paddle.boat.kayak': '카약',
diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts
index 8504f2e9b..3f53ffd92 100644
--- a/shared/src/i18n/nl/tours.ts
+++ b/shared/src/i18n/nl/tours.ts
@@ -609,6 +609,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Aankomst na de start van {walk} om {time}',
'tours.poi.categoriesNone': 'Geen categorie gekozen',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Topper',
+ 'tours.poi.highlightHint': 'Een van de bekendste plekken hier',
'tours.planner.tourType': 'Soort tocht',
'tours.paddle.boat': 'Boot',
'tours.paddle.boat.kayak': 'Kajak',
diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts
index fe48b87bb..4e3fb6738 100644
--- a/shared/src/i18n/pl/tours.ts
+++ b/shared/src/i18n/pl/tours.ts
@@ -617,6 +617,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Przyjazd po rozpoczęciu trasy {walk} o {time}',
'tours.poi.categoriesNone': 'Nie wybrano kategorii',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Perełka',
+ 'tours.poi.highlightHint': 'Jedno z najbardziej znanych miejsc w okolicy',
'tours.planner.tourType': 'Rodzaj wycieczki',
'tours.paddle.boat': 'Łódź',
'tours.paddle.boat.kayak': 'Kajak',
diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts
index bb6b3ccec..27d573ab3 100644
--- a/shared/src/i18n/ru/tours.ts
+++ b/shared/src/i18n/ru/tours.ts
@@ -620,6 +620,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Прибытие после начала прогулки {walk} в {time}',
'tours.poi.categoriesNone': 'Категория не выбрана',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Главное',
+ 'tours.poi.highlightHint': 'Одно из самых известных мест здесь',
'tours.planner.tourType': 'Тип тура',
'tours.paddle.boat': 'Лодка',
'tours.paddle.boat.kayak': 'Каяк',
diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts
index 8ad7f27f1..e73a1ca48 100644
--- a/shared/src/i18n/sk/tours.ts
+++ b/shared/src/i18n/sk/tours.ts
@@ -597,6 +597,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Príchod po začiatku prechádzky {walk} o {time}',
'tours.poi.categoriesNone': 'Žiadna kategória',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Top miesto',
+ 'tours.poi.highlightHint': 'Jedno z najznámejších miest v okolí',
'tours.planner.tourType': 'Typ túry',
'tours.paddle.boat': 'Loď',
'tours.paddle.boat.kayak': 'Kajak',
diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts
index a8272be1a..5ee064527 100644
--- a/shared/src/i18n/sv/tours.ts
+++ b/shared/src/i18n/sv/tours.ts
@@ -596,6 +596,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Ankomst efter att {walk} börjar kl. {time}',
'tours.poi.categoriesNone': 'Ingen kategori vald',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Höjdpunkt',
+ 'tours.poi.highlightHint': 'En av de mest kända platserna här',
'tours.planner.tourType': 'Turtyp',
'tours.paddle.boat': 'Båt',
'tours.paddle.boat.kayak': 'Kajak',
diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts
index 5558452a2..86f225349 100644
--- a/shared/src/i18n/th/tours.ts
+++ b/shared/src/i18n/th/tours.ts
@@ -571,6 +571,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'ถึงหลังจาก {walk} เริ่มเวลา {time}',
'tours.poi.categoriesNone': 'ยังไม่ได้เลือกหมวดหมู่',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'ไฮไลต์',
+ 'tours.poi.highlightHint': 'หนึ่งในสถานที่ที่มีชื่อเสียงที่สุดในบริเวณนี้',
'tours.planner.tourType': 'ประเภททริป',
'tours.paddle.boat': 'เรือ',
'tours.paddle.boat.kayak': 'เรือคายัก',
diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts
index bec5381d3..bd1176f76 100644
--- a/shared/src/i18n/tours-locales.spec.ts
+++ b/shared/src/i18n/tours-locales.spec.ts
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
-const GENERAL_KEY_COUNT = 823;
+const GENERAL_KEY_COUNT = 825;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts
index 023f690c6..6208378d6 100644
--- a/shared/src/i18n/tr/tours.ts
+++ b/shared/src/i18n/tr/tours.ts
@@ -603,6 +603,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Varış, {walk} {time} saatinde başladıktan sonra',
'tours.poi.categoriesNone': 'Kategori seçilmedi',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Öne çıkan',
+ 'tours.poi.highlightHint': 'Buradaki en bilinen yerlerden biri',
'tours.planner.tourType': 'Tur türü',
'tours.paddle.boat': 'Tekne',
'tours.paddle.boat.kayak': 'Kano (kayak)',
diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts
index 5e6a8dada..bb05144bf 100644
--- a/shared/src/i18n/uk/tours.ts
+++ b/shared/src/i18n/uk/tours.ts
@@ -613,6 +613,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Прибуття після початку прогулянки {walk} о {time}',
'tours.poi.categoriesNone': 'Категорію не вибрано',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Головне',
+ 'tours.poi.highlightHint': 'Одне з найвідоміших місць тут',
'tours.planner.tourType': 'Тип туру',
'tours.paddle.boat': 'Човен',
'tours.paddle.boat.kayak': 'Каяк',
diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts
index ff0488e64..dcf9998fc 100644
--- a/shared/src/i18n/vi/tours.ts
+++ b/shared/src/i18n/vi/tours.ts
@@ -595,6 +595,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Đến sau khi {walk} bắt đầu lúc {time}',
'tours.poi.categoriesNone': 'Chưa chọn danh mục',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': 'Nổi bật',
+ 'tours.poi.highlightHint': 'Một trong những nơi nổi tiếng nhất ở đây',
'tours.planner.tourType': 'Loại chuyến',
'tours.paddle.boat': 'Thuyền',
'tours.paddle.boat.kayak': 'Thuyền kayak',
diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts
index dcda33da4..8060c0a99 100644
--- a/shared/src/i18n/zh-TW/tours.ts
+++ b/shared/src/i18n/zh-TW/tours.ts
@@ -551,6 +551,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': '抵達晚於 {walk} 的開始時間 {time}',
'tours.poi.categoriesNone': '未選擇類別',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': '亮點',
+ 'tours.poi.highlightHint': '這裡最知名的地方之一',
'tours.planner.tourType': '路線類型',
'tours.paddle.boat': '船隻',
'tours.paddle.boat.kayak': '獨木舟(Kayak)',
diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts
index de58b1cf5..9237d2f40 100644
--- a/shared/src/i18n/zh/tours.ts
+++ b/shared/src/i18n/zh/tours.ts
@@ -552,6 +552,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': '到达晚于 {walk} 的开始时间 {time}',
'tours.poi.categoriesNone': '未选择类别',
'tours.poi.categoriesMore': '{first} +{more}',
+ 'tours.poi.highlight': '亮点',
+ 'tours.poi.highlightHint': '这里最知名的地方之一',
'tours.planner.tourType': '路线类型',
'tours.paddle.boat': '船只',
'tours.paddle.boat.kayak': '皮划艇',
diff --git a/shared/src/tours/tour-highlights.spec.ts b/shared/src/tours/tour-highlights.spec.ts
new file mode 100644
index 000000000..72921033a
--- /dev/null
+++ b/shared/src/tours/tour-highlights.spec.ts
@@ -0,0 +1,42 @@
+import {
+ TOUR_HIGHLIGHT_FALLBACK_MIN_QRANK,
+ TOUR_HIGHLIGHT_MIN_COUNT,
+ TOUR_HIGHLIGHT_MIN_QRANK,
+ tourHighlightIds,
+} from './tour-highlights';
+
+import { describe, expect, it } from 'vitest';
+
+const rank = (id: string, qrank: number) => ({ id, qrank });
+
+describe('tour highlights', () => {
+ it('TOUR-HIGHLIGHT-001: every place past the bar is a highlight, best known first', () => {
+ expect(
+ tourHighlightIds([
+ rank('square', 900_000),
+ rank('cafe', 100),
+ rank('church', 41_000),
+ rank('bridge', 40_000),
+ rank('tower', 250_000),
+ ]),
+ ).toEqual(['square', 'tower', 'church', 'bridge']);
+ });
+
+ it('TOUR-HIGHLIGHT-002: with fewer than three past the bar, the best known down to the fallback fill up to three', () => {
+ expect(
+ tourHighlightIds([rank('a', 6_000), rank('b', 60_000), rank('c', 4_999), rank('d', 5_000), rank('e', 7_000)]),
+ ).toEqual(['b', 'e', 'a']);
+ expect(tourHighlightIds([rank('a', 6_000), rank('b', 4_000)])).toEqual(['a']);
+ });
+
+ it('TOUR-HIGHLIGHT-003: nothing ranked is nothing marked, and a broken rank is never a highlight', () => {
+ expect(tourHighlightIds([])).toEqual([]);
+ expect(tourHighlightIds([rank('a', 4_999), rank('b', Number.NaN)])).toEqual([]);
+ });
+
+ it('TOUR-HIGHLIGHT-004: the bars are what the rule says', () => {
+ expect(TOUR_HIGHLIGHT_MIN_QRANK).toBe(40_000);
+ expect(TOUR_HIGHLIGHT_FALLBACK_MIN_QRANK).toBe(5_000);
+ expect(TOUR_HIGHLIGHT_MIN_COUNT).toBe(3);
+ });
+});
diff --git a/shared/src/tours/tour-highlights.ts b/shared/src/tours/tour-highlights.ts
new file mode 100644
index 000000000..159cec7fa
--- /dev/null
+++ b/shared/src/tours/tour-highlights.ts
@@ -0,0 +1,35 @@
+/**
+ * Which places along a tour are its highlights: the ones people know.
+ *
+ * Fame is read from QRank, the page views of a place's Wikidata item across
+ * the Wikimedia projects, as the TREK places service carries it on its
+ * OpenStreetMap layer. A place without a Wikidata item has no rank and is
+ * never a highlight; that says nothing about whether it is worth a visit.
+ */
+
+/** A place at least this well known is a highlight wherever it lies. */
+export const TOUR_HIGHLIGHT_MIN_QRANK = 40_000;
+
+/**
+ * A route through quieter streets still shows its best known places: when
+ * fewer than {@link TOUR_HIGHLIGHT_MIN_COUNT} reach the bar above, the best
+ * known ones down to this rank fill up to that many.
+ */
+export const TOUR_HIGHLIGHT_FALLBACK_MIN_QRANK = 5_000;
+
+/** How many highlights the fallback fills up to. */
+export const TOUR_HIGHLIGHT_MIN_COUNT = 3;
+
+/**
+ * The ids of the highlights among places with a rank, best known first. Ties
+ * keep the order they came in, so the same answer marks the same places.
+ */
+export function tourHighlightIds(ranked: ReadonlyArray<{ id: string; qrank: number }>): string[] {
+ const best = [...ranked].filter((row) => Number.isFinite(row.qrank)).sort((a, b) => b.qrank - a.qrank);
+ const famous = best.filter((row) => row.qrank >= TOUR_HIGHLIGHT_MIN_QRANK);
+ if (famous.length >= TOUR_HIGHLIGHT_MIN_COUNT) return famous.map((row) => row.id);
+ return best
+ .filter((row) => row.qrank >= TOUR_HIGHLIGHT_FALLBACK_MIN_QRANK)
+ .slice(0, TOUR_HIGHLIGHT_MIN_COUNT)
+ .map((row) => row.id);
+}
diff --git a/shared/src/tours/tour-poi-search.schema.spec.ts b/shared/src/tours/tour-poi-search.schema.spec.ts
index 90084de55..ed03e9f60 100644
--- a/shared/src/tours/tour-poi-search.schema.spec.ts
+++ b/shared/src/tours/tour-poi-search.schema.spec.ts
@@ -72,6 +72,29 @@ describe('tour POI search contracts (PLN-02)', () => {
expect(answer.pois[0]?.distance_m).toBe(40);
});
+ it('TOUR-HIGHLIGHT-005: marks a highlight with a flag that may be left off, and refuses one that is no flag', () => {
+ const poi = {
+ id: 'way:9',
+ name: 'Piazza San Marco',
+ lat: 45.434,
+ lng: 12.338,
+ category: 'sights',
+ kind: 'place=square',
+ source: 'openstreetmap',
+ address: null,
+ website: null,
+ phone: null,
+ opening_hours: null,
+ distance_m: 12,
+ along_m: 300,
+ };
+ const answer = { truncated: false, partial: false, capped: false, unavailable: [] };
+ const parsed = tourPoiSearchResponseSchema.parse({ ...answer, pois: [{ ...poi, highlight: true }, poi] });
+ expect(parsed.pois.map((found) => found.highlight)).toEqual([true, undefined]);
+ const notAFlag = { ...answer, pois: [{ ...poi, highlight: 'yes' }] };
+ expect(tourPoiSearchResponseSchema.safeParse(notAFlag).success).toBe(false);
+ });
+
it('names the kinds that could not be searched, each a known category, and always says so', () => {
const answer = { pois: [], truncated: false, partial: false, capped: false };
expect(tourPoiSearchResponseSchema.parse({ ...answer, unavailable: ['viewpoint'] }).unavailable).toEqual([
diff --git a/shared/src/tours/tour-poi-search.schema.ts b/shared/src/tours/tour-poi-search.schema.ts
index d68ac70ee..e02aadb06 100644
--- a/shared/src/tours/tour-poi-search.schema.ts
+++ b/shared/src/tours/tour-poi-search.schema.ts
@@ -12,6 +12,13 @@ import { z } from 'zod';
// The categories live in a module of their own, so the tour contract can name
// one on a waypoint without importing this one, which imports it.
export { TOUR_POI_CATEGORIES, tourPoiCategorySchema, type TourPoiCategory } from './tour-poi-category';
+// Which places found are highlights, read by the server that marks them.
+export {
+ TOUR_HIGHLIGHT_FALLBACK_MIN_QRANK,
+ TOUR_HIGHLIGHT_MIN_COUNT,
+ TOUR_HIGHLIGHT_MIN_QRANK,
+ tourHighlightIds,
+} from './tour-highlights';
/** The categories each planned tour type offers, the most asked first. */
export const TOUR_POI_CATEGORIES_BY_TYPE: Record = {
@@ -80,6 +87,8 @@ export const tourPoiSchema = z.object({
distance_m: z.number().nonnegative(),
/** Metres along the line from its start to the point nearest the place. */
along_m: z.number().nonnegative(),
+ /** One of the best known places along the line (tour-highlights); absent means not one. */
+ highlight: z.boolean().optional(),
});
export type TourPoi = z.infer;
diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md
index 4b622466d..dd1e4da9b 100644
--- a/wiki/MCP-Addon-Tools.md
+++ b/wiki/MCP-Addon-Tools.md
@@ -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 tour within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental, bike repair, 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. 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 tour within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental, bike repair, 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` |
| `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` |
| `take_over_tour` | Copies a saved tour of another trip into a trip as an independent tour: its line, measures, settings and control points, without its days, its links to places of the other trip or the obstacle decisions taken there. The copy remembers which trip and tour it came from; the source trip is not changed. A city walk keeps its stations with their keys, names, stays, importance, protection and opening hours, and none of the visits of the other trip's days. Needs `place_edit` on the trip it writes into | `trips:write` |
| `preview_google_maps_walk` | Reads the stops of a Google Maps walking route shared as a link (a `maps.app.goo.gl` short link or a `google.com/maps/dir` link), in order, for a city walk: each stop's name and position, or no position when the link gives none (nothing is looked up or guessed), and the long link that was read. Only a route Google Maps marks as on foot is read; a car, bike or transit route, or a link that does not say, is refused. A short link is followed only to Google Maps itself. Nothing is saved, and the line drawn in Google Maps is not taken over: TREK plans the way on foot between the stops again. Needs `place_edit` on the trip | `trips:read` |