diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 07f4c0ac3..11f4eb010 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -934,7 +934,7 @@ export const mapsApi = { // the caller is expected to abort it when the selection changes, and a longer // timeout than the global 8s — a cold Wikimedia connection alone can eat that. placeEnrichment: ( - body: { placeId?: string; lat: number; lng: number; name: string; lang?: string }, + body: { placeId?: string; lat: number; lng: number; name: string; lang?: string; details?: Record }, signal?: AbortSignal, ) => apiClient.post('/maps/enrichment', body, { signal, timeout: 25000 }) .then(r => checkInDev(mapsPlaceEnrichmentResultSchema, r.data, 'maps.placeEnrichment')), diff --git a/client/src/components/Planner/PlaceDetailsColumn.tsx b/client/src/components/Planner/PlaceDetailsColumn.tsx index c8b8b3f84..31ae4b2d8 100644 --- a/client/src/components/Planner/PlaceDetailsColumn.tsx +++ b/client/src/components/Planner/PlaceDetailsColumn.tsx @@ -13,6 +13,7 @@ import { ScrollText, ShoppingBag, Sprout, + Star, Sun, Wifi, } from 'lucide-react' @@ -26,6 +27,8 @@ export interface PlaceDetailsSelection { lat: number lng: number name: string + /** The picked search result, so the server can skip its own details lookup. */ + details?: Record } interface PlaceDetailsColumnProps { @@ -128,7 +131,14 @@ export default function PlaceDetailsColumn({ mapsApi .placeEnrichment( - { placeId: selection.placeId, lat: selection.lat, lng: selection.lng, name: selection.name, lang: language }, + { + placeId: selection.placeId, + lat: selection.lat, + lng: selection.lng, + name: selection.name, + lang: language, + details: selection.details, + }, controller.signal, ) .then((result) => { @@ -204,11 +214,17 @@ export default function PlaceDetailsColumn({ } /** - * The picture strip: the first candidate wide, the rest as a row underneath. + * The picture strip. * - * A plain two-column grid left a hole whenever an odd number of pictures came - * back, which is most of the time. A lead image also gives the column something - * to look at instead of a page of equal squares. + * A tight three-column grid rather than a lead image: this sits beside a form + * in a dialog, and pictures that take half the column push the facts and the + * description out of sight. + * + * Only the picture in play is credited in full. Crediting all of them at once + * cost two lines each and drowned the column, and the licence obligation + * attaches to the picture that gets used — which is the selected one, whose + * credit also stays visible in the inspector after saving. Every tile still + * carries the full credit as its tooltip and links to its source page. */ function PhotoStrip({ photos, @@ -221,88 +237,95 @@ function PhotoStrip({ onPickImage: (url: string | null) => void t: TranslationFn }): React.ReactElement | null { + const [hovered, setHovered] = useState(null) if (photos.length === 0) return null - const [lead, ...rest] = photos + const shown = photos.find((p) => p.url === (hovered ?? selectedImageUrl)) ?? photos[0] return (
{t('places.details.pickImage')} - - {rest.length > 0 && ( -
- {rest.map((photo) => ( - - ))} -
- )} +
+ {photos.map((photo) => ( + + ))} +
+
) } function PhotoTile({ photo, - wide = false, selected, onPick, + onHover, t, }: { photo: PlacePhotoCandidate - wide?: boolean selected: boolean onPick: (url: string | null) => void + onHover: (url: string | null) => void t: TranslationFn }): React.ReactElement { + const credit = photo.attribution || sourceLabelFor(photo.source) + return ( -
- - -
+ ) } +function sourceLabelFor(source: PlacePhotoCandidate['source']): string { + if (source === 'google') return 'Google' + if (source === 'wikipedia') return 'Wikipedia' + return 'Wikimedia Commons' +} + /** - * Author and licence under every thumbnail. + * Author and licence for the picture currently in play. * * Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one - * without naming its author does not satisfy those terms. Wrapped over two - * lines rather than truncated to one, because a cut-off name credits nobody. - * When a source hands us no author (Google), we say where it came from rather - * than inventing a credit. + * without naming its author does not satisfy those terms. When a source hands + * us no author (Google), we say where it came from rather than inventing one. */ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElement { - const sourceLabel = photo.source === 'google' ? 'Google' : photo.source === 'wikipedia' ? 'Wikipedia' : 'Wikimedia Commons' - const credit = photo.attribution || sourceLabel + const credit = photo.attribution || sourceLabelFor(photo.source) const full = `${credit}${photo.license ? ` · ${photo.license}` : ''}` return ( -

+

{photo.sourceUrl ? ( {credit} @@ -327,6 +350,7 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem } const FACT_ICONS: Record = { + rating: Star, cuisine: ChefHat, openingHours: Clock, menu: ScrollText, diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index 155b31f0e..24d1b73b1 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -1014,6 +1014,21 @@ describe('PlaceFormModal', () => { expect(screen.getByText('screenshot.png')).toBeInTheDocument(); }); + it('FE-PLANNER-PLACEFORM-057: a prefilled place drives the detail column, not the previous one', () => { + // A POI tapped on the map opens the dialog through prefillCoords, never + // through the search handler — the column used to keep the last place. + const { rerender } = render( + , + ); + expect(screen.getByDisplayValue('Helgas Kitchen')).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByDisplayValue('Museum Ludwig')).toBeInTheDocument(); + expect(screen.queryByDisplayValue('Helgas Kitchen')).not.toBeInTheDocument(); + }); + it('FE-PLANNER-PLACEFORM-055: pasting plain text alone attaches nothing', () => { render(); const form = document.querySelector('form') as HTMLFormElement; diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 15a2ff064..8cf98376f 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -138,6 +138,28 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } setPendingFiles([]) setDuplicateWarning(null) + // The column follows whatever the dialog was opened with, not only a search + // pick: a POI tapped on the map and a right-click place arrive as + // prefillCoords, and editing an existing place arrives as `place`. Without + // this the column kept showing the previous place's pictures and facts, + // because only handleSelectMapsResult ever set it. + if (place && place.lat != null && place.lng != null) { + setDetailsSelection({ + placeId: place.google_place_id || place.osm_id || undefined, + lat: Number(place.lat), + lng: Number(place.lng), + name: place.name || '', + }) + } else if (prefillCoords) { + setDetailsSelection({ + placeId: prefillCoords.osm_id || undefined, + lat: prefillCoords.lat, + lng: prefillCoords.lng, + name: prefillCoords.name || '', + }) + } else { + setDetailsSelection(null) + } // dayAssignments is a fresh array each render; read it at open-time only and // re-run on identity changes (place/assignmentId/open), not on every render. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -280,6 +302,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) { lat, lng, name: result.name || '', + // Hand the record along: the server needs the same OSM tags, and + // looking them up again costs an Overpass round trip it can skip. + details: result, }) setForm(prev => ({ ...prev, image_url: undefined })) } diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index d3e37fdc4..e0355a7a0 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -729,6 +729,51 @@ export class MapsService { return null; } + /** + * The one picture Wikidata records for a place (property P18). + * + * By far the most accurate source there is: someone chose this image to + * represent this exact object, where a coordinate search only knows what was + * photographed nearby. Restaurants and small venues carry a `wikidata` tag far + * more often than a Commons category, so this is also the source that reaches + * the places the others miss. + */ + async fetchWikidataImage(wikidataId: string): Promise { + if (!/^Q\d+$/.test(wikidataId.trim())) return null; + try { + const claims = await fetch( + `https://www.wikidata.org/w/api.php?action=wbgetclaims&entity=${wikidataId.trim()}&property=P18&format=json`, + { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) }, + ); + if (!claims.ok) return null; + const data = (await claims.json()) as { + claims?: { P18?: { mainsnak?: { datavalue?: { value?: string } } }[] }; + }; + const fileName = data.claims?.P18?.[0]?.mainsnak?.datavalue?.value; + if (!fileName) return null; + + // Second hop for the URL and, more importantly, the licence — the claim + // itself carries only a file name. + const params = new URLSearchParams({ + action: 'query', + format: 'json', + titles: `File:${fileName}`, + prop: 'imageinfo', + iiprop: 'url|extmetadata|mime', + iiurlwidth: '400', + }); + const file = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { + headers: { 'User-Agent': UA }, + signal: AbortSignal.timeout(WIKI_TIMEOUT_MS), + }); + if (!file.ok) return null; + const fileData = (await file.json()) as { query?: { pages?: Record } }; + return this.toCommonsCandidates(fileData.query?.pages, 1)[0] ?? null; + } catch { + return null; + } + } + /** * Commons images from a category, which is the set of pictures OF a place. * diff --git a/server/src/nest/place-enrichment/place-enrichment.service.ts b/server/src/nest/place-enrichment/place-enrichment.service.ts index e844029a5..eafb4216a 100644 --- a/server/src/nest/place-enrichment/place-enrichment.service.ts +++ b/server/src/nest/place-enrichment/place-enrichment.service.ts @@ -31,6 +31,13 @@ const GOOGLE_CAP = 3; */ const CACHE_KIND = 2; const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +/** + * Bumped whenever the shape or the sources change. Without it a release that + * adds a field or swaps a provider keeps serving the previous week's answers + * from before the change — which is exactly what happened when the facts and + * Wikivoyage landed. + */ +const CACHE_VERSION = 2; /** Cache key for one candidate picture. Deliberately not a bare place id. */ function candidateKey(placeId: string, index: number): string { @@ -56,6 +63,10 @@ interface CachedEnrichment { facts: PlaceFact[]; } +interface CachePayload extends CachedEnrichment { + v?: number; +} + /** OSM yes/no tags; anything else (limited, only, designated) is shown verbatim. */ function yesNo(value: unknown): 'yes' | 'no' | string | null { return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null; @@ -73,19 +84,30 @@ function yesNo(value: unknown): 'yes' | 'no' | string | null { * and a deliberate `no` are indistinguishable to a reader either way. */ export function collectFacts(details: Record | null): PlaceFact[] { - if (!details || details.source !== 'openstreetmap') return []; + if (!details) return []; const facts: PlaceFact[] = []; const push = (kind: PlaceFact['kind'], value: string | null, url: string | null = null) => facts.push({ kind, value, url }); - const cuisine = typeof details.cuisine === 'string' ? details.cuisine : null; - // OSM writes several cuisines semicolon-separated and underscored. - if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replace(/_/g, ' ').trim()).filter(Boolean).join(', ')); + // Google supplies a rating and hours as well; both come back from the same + // details lookup and were simply going unused. + const rating = typeof details.rating === 'number' ? details.rating : null; + if (rating) { + const count = typeof details.rating_count === 'number' ? details.rating_count : null; + push('rating', count ? `${rating} (${count})` : String(rating)); + } const hours = Array.isArray(details.opening_hours) ? (details.opening_hours as string[]) : null; if (hours?.length) push('openingHours', hours.join(' · ')); + // Everything below is OpenStreetMap tagging and has no Google equivalent. + if (details.source !== 'openstreetmap') return facts; + + const cuisine = typeof details.cuisine === 'string' ? details.cuisine : null; + // OSM writes several cuisines semicolon-separated and underscored. + if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replace(/_/g, ' ').trim()).filter(Boolean).join(', ')); + const menu = typeof details.menu_url === 'string' ? details.menu_url : null; if (menu) push('menu', null, menu); @@ -148,7 +170,10 @@ export class PlaceEnrichmentService { // category, the description needs its wiki tag, and the facts are its tags. // It is row-cached and the dialog just made the same call, so in practice // this is a cache read. - const details = await this.readDetails(userId, placeId, lang); + // The dialog already fetched this when the user picked the result, so it + // comes along with the request. Refetching cost 12.8 seconds on a large + // OSM relation, and it ran in parallel with the client's own lookup. + const details = req.details ?? (await this.readDetails(userId, placeId, lang)); const [photos, description] = await Promise.all([ this.collectPhotos(userId, placeId, req, details), @@ -184,12 +209,16 @@ export class PlaceEnrichmentService { // Ask both providers at once — one listing call each, and they know nothing // about one another. - // A Commons category is pictures OF the place; the coordinate search is - // pictures NEAR it, which around a city centre means statues and passers-by. - // Prefer the category whenever the place carries one. + // Accuracy, best first. Wikidata's P18 is one picture somebody chose to + // represent this exact object. A Commons category is pictures of it. The + // coordinate search is only pictures NEAR it, which around a city centre + // means statues and passers-by — it is the last resort, not the plan. const category = typeof details?.wikimedia_commons === 'string' ? details.wikimedia_commons : null; - const [googleRefs, commons] = await Promise.all([ + const wikidata = typeof details?.wikidata === 'string' ? details.wikidata : null; + + const [googleRefs, wikidataImage, commons] = await Promise.all([ wantsGoogle ? this.maps.fetchGooglePhotoRefs(placeId, apiKey!, GOOGLE_CAP) : Promise.resolve([]), + wikidata ? this.maps.fetchWikidataImage(wikidata) : Promise.resolve(null), category ? this.maps .fetchCommonsCategoryCandidates(category, COMMONS_CAP) @@ -197,6 +226,12 @@ export class PlaceEnrichmentService { : this.maps.fetchCommonsCandidates(req.lat, req.lng, COMMONS_CAP), ]); + // The Wikidata picture leads, and is dropped from the rest of the list when + // a category happens to contain it too. + const commonsList = wikidataImage + ? [wikidataImage, ...commons.filter((c) => c.photoUrl !== wikidataImage.photoUrl)].slice(0, COMMONS_CAP) + : commons; + // Google first — its pictures are of the place itself, while Commons // geosearch finds whatever happens to have been photographed nearby. const pending: PlacePhotoCandidate[] = [ @@ -211,7 +246,7 @@ export class PlaceEnrichmentService { sourceUrl: null, source: 'google' as const, })), - ...commons.map((candidate) => ({ + ...commonsList.map((candidate) => ({ key: '', url: '', attribution: candidate.attribution, @@ -232,7 +267,7 @@ export class PlaceEnrichmentService { const fetchBytes = candidate.source === 'google' ? () => this.maps.fetchGooglePhotoBytes(googleRefs[index].name, apiKey!) - : () => this.fetchRemoteBytes(commons[index - googleRefs.length].photoUrl); + : () => this.fetchRemoteBytes(commonsList[index - googleRefs.length].photoUrl); const url = await this.storeCandidate(key, creditLine(candidate.attribution, candidate.license), fetchBytes); // The index is fixed before any download, so a picture that fails to // load leaves a gap rather than renumbering the ones after it — the key @@ -361,7 +396,8 @@ export class PlaceEnrichmentService { CACHE_KIND, ); if (!row || Date.now() - row.fetched_at >= CACHE_TTL_MS) return null; - const parsed = JSON.parse(row.payload_json) as CachedEnrichment; + const parsed = JSON.parse(row.payload_json) as CachePayload; + if (parsed.v !== CACHE_VERSION) return null; // A cached candidate is only usable while its bytes are still on disk, and // the nightly sweep deletes every picture nobody picked — which is most of @@ -383,7 +419,7 @@ export class PlaceEnrichmentService { placeId, lang ?? '', CACHE_KIND, - JSON.stringify(value), + JSON.stringify({ ...value, v: CACHE_VERSION } satisfies CachePayload), Date.now(), ); } catch (err) { diff --git a/server/tests/unit/nest/place-enrichment.service.test.ts b/server/tests/unit/nest/place-enrichment.service.test.ts index d20c115d0..46dff0930 100644 --- a/server/tests/unit/nest/place-enrichment.service.test.ts +++ b/server/tests/unit/nest/place-enrichment.service.test.ts @@ -471,8 +471,10 @@ describe('collectDescription', () => { // ── Result cache ───────────────────────────────────────────────────────────── describe('result cache', () => { - const cachedRow = (photos: unknown[], fetchedAt = Date.now()) => ({ - payload_json: JSON.stringify({ photos, description: null }), + // v is the cache format version; an entry written before a release that + // changes the shape or the sources is discarded rather than served. + const cachedRow = (photos: unknown[], fetchedAt = Date.now(), v: number | undefined = 2) => ({ + payload_json: JSON.stringify({ photos, description: null, facts: [], v }), fetched_at: fetchedAt, }); @@ -543,6 +545,18 @@ describe('result cache', () => { expect(maps.fetchCommonsCandidates).not.toHaveBeenCalled(); }); + it('ENRICH-023c: discards an entry written by an older format version', async () => { + // Otherwise a release that adds a field keeps serving last week's answers. + const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) }); + mockDbGet.mockImplementation((sql: string) => + String(sql).includes('place_details_cache') ? cachedRow([], Date.now(), 1) : undefined, + ); + + await make(maps, cacheStub()).enrich(1, REQ); + + expect(maps.fetchCommonsCandidates).toHaveBeenCalled(); + }); + it('ENRICH-024: ignores an unreadable cache row and rebuilds', async () => { const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) }); mockDbGet.mockImplementation((sql: string) => @@ -630,9 +644,10 @@ describe('collectFacts', () => { ); expect(facts).toEqual([ + // Hours come from the block shared with Google, so they lead. + { kind: 'openingHours', value: 'Mo-Sa 17:30+', url: null }, // Semicolons and underscores are OSM syntax, not something to show a reader. { kind: 'cuisine', value: 'regional, german', url: null }, - { kind: 'openingHours', value: 'Mo-Sa 17:30+', url: null }, { kind: 'menu', value: null, url: 'https://example.org/menu' }, { kind: 'outdoorSeating', value: null, url: null }, { kind: 'wheelchair', value: 'limited', url: null }, @@ -659,9 +674,20 @@ describe('collectFacts', () => { expect(facts.find((f) => f.kind === 'internetAccess')).toEqual({ kind: 'internetAccess', value: null, url: null }); }); - it('ENRICH-053: stays empty for a Google place and for no details at all', () => { + it('ENRICH-053: takes the rating and hours from a Google place, but not its OSM-only tags', () => { expect(collectFacts(null)).toEqual([]); - expect(collectFacts({ source: 'google', cuisine: 'italian' })).toEqual([]); + // cuisine/outdoor_seating are OSM tagging and never appear on a Google blob. + expect(collectFacts({ source: 'google', cuisine: 'italian', outdoor_seating: 'yes' })).toEqual([]); + expect( + collectFacts({ source: 'google', rating: 4.5, rating_count: 1234, opening_hours: ['Mo-Fr 09:00-18:00'] }), + ).toEqual([ + { kind: 'rating', value: '4.5 (1234)', url: null }, + { kind: 'openingHours', value: 'Mo-Fr 09:00-18:00', url: null }, + ]); + }); + + it('ENRICH-053b: omits the review count when there is none', () => { + expect(collectFacts({ source: 'google', rating: 4 })).toEqual([{ kind: 'rating', value: '4', url: null }]); }); it('ENRICH-054: joins multi-day opening hours into one readable line', () => { diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index b3ab9d2d7..79e3ecbb4 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'أفرغ حقل الوصف أولًا', 'places.details.facts': 'معلومات مفيدة', 'places.details.nothing': 'لم يتم العثور على شيء لهذا المكان.', + 'places.details.fact.rating': 'التقييم', 'places.details.fact.cuisine': 'المطبخ', 'places.details.fact.openingHours': 'ساعات العمل', 'places.details.fact.menu': 'قائمة الطعام', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index ea9c8f32f..eb80c6760 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Esvazie primeiro o campo de descrição', 'places.details.facts': 'Bom saber', 'places.details.nothing': 'Nada encontrado para este lugar.', + 'places.details.fact.rating': 'Avaliação', 'places.details.fact.cuisine': 'Cozinha', 'places.details.fact.openingHours': 'Horário', 'places.details.fact.menu': 'Cardápio', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index afa9cc0d1..02625c97d 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -95,6 +95,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Buida primer el camp de descripció', 'places.details.facts': 'Bo saber', 'places.details.nothing': "No s'ha trobat res per a aquest lloc.", + 'places.details.fact.rating': 'Valoració', 'places.details.fact.cuisine': 'Cuina', 'places.details.fact.openingHours': 'Horari', 'places.details.fact.menu': 'Carta', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 4db9ce406..2de4e44d0 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Nejdřív vyprázdni pole popisu', 'places.details.facts': 'Dobré vědět', 'places.details.nothing': 'Pro toto místo se nic nenašlo.', + 'places.details.fact.rating': 'Hodnocení', 'places.details.fact.cuisine': 'Kuchyně', 'places.details.fact.openingHours': 'Otevírací doba', 'places.details.fact.menu': 'Menu', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 7ee7a3da1..dde1a1bc0 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Erst das Beschreibungsfeld leeren', 'places.details.facts': 'Gut zu wissen', 'places.details.nothing': 'Zu diesem Ort wurde nichts gefunden.', + 'places.details.fact.rating': 'Bewertung', 'places.details.fact.cuisine': 'Küche', 'places.details.fact.openingHours': 'Öffnungszeiten', 'places.details.fact.menu': 'Speisekarte', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index ad256ead5..36684268f 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Clear the description field first', 'places.details.facts': 'Good to know', 'places.details.nothing': 'Nothing found for this place.', + 'places.details.fact.rating': 'Rating', 'places.details.fact.cuisine': 'Cuisine', 'places.details.fact.openingHours': 'Opening hours', 'places.details.fact.menu': 'Menu', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index e093ad3bc..7b9edac6b 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Vacía primero el campo de descripción', 'places.details.facts': 'Bueno saber', 'places.details.nothing': 'No se encontró nada para este lugar.', + 'places.details.fact.rating': 'Valoración', 'places.details.fact.cuisine': 'Cocina', 'places.details.fact.openingHours': 'Horario', 'places.details.fact.menu': 'Carta', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index 38587792d..86d9f7a0a 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': "Videz d'abord le champ description", 'places.details.facts': 'Bon à savoir', 'places.details.nothing': 'Rien trouvé pour ce lieu.', + 'places.details.fact.rating': 'Note', 'places.details.fact.cuisine': 'Cuisine', 'places.details.fact.openingHours': 'Horaires', 'places.details.fact.menu': 'Carte', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index f48bd5d48..ec0df6355 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Άδειασε πρώτα το πεδίο περιγραφής', 'places.details.facts': 'Καλό να ξέρεις', 'places.details.nothing': 'Δεν βρέθηκε τίποτα για αυτήν την τοποθεσία.', + 'places.details.fact.rating': 'Βαθμολογία', 'places.details.fact.cuisine': 'Κουζίνα', 'places.details.fact.openingHours': 'Ωράριο', 'places.details.fact.menu': 'Μενού', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index 2fed6e805..c1841809f 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Előbb ürítsd ki a leírás mezőt', 'places.details.facts': 'Jó tudni', 'places.details.nothing': 'Ehhez a helyhez nem található semmi.', + 'places.details.fact.rating': 'Értékelés', 'places.details.fact.cuisine': 'Konyha', 'places.details.fact.openingHours': 'Nyitvatartás', 'places.details.fact.menu': 'Étlap', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index 6522bb154..3ed33a08d 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -96,6 +96,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Kosongkan dulu kolom deskripsi', 'places.details.facts': 'Perlu diketahui', 'places.details.nothing': 'Tidak ada yang ditemukan untuk tempat ini.', + 'places.details.fact.rating': 'Peringkat', 'places.details.fact.cuisine': 'Masakan', 'places.details.fact.openingHours': 'Jam buka', 'places.details.fact.menu': 'Menu', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index ec03063c1..b6df48d7a 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Svuota prima il campo descrizione', 'places.details.facts': 'Da sapere', 'places.details.nothing': 'Nessun risultato per questo luogo.', + 'places.details.fact.rating': 'Valutazione', 'places.details.fact.cuisine': 'Cucina', 'places.details.fact.openingHours': 'Orari', 'places.details.fact.menu': 'Menu', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index 57f753bbf..d9d547f99 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': '先に説明欄を空にしてください', 'places.details.facts': '知っておくと便利', 'places.details.nothing': 'この場所の情報は見つかりませんでした。', + 'places.details.fact.rating': '評価', 'places.details.fact.cuisine': '料理', 'places.details.fact.openingHours': '営業時間', 'places.details.fact.menu': 'メニュー', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index 2a921b59b..e23d9d8d9 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -96,6 +96,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': '설명란을 먼저 비워 주세요', 'places.details.facts': '알아두면 좋아요', 'places.details.nothing': '이 장소에 대한 정보를 찾지 못했습니다.', + 'places.details.fact.rating': '평점', 'places.details.fact.cuisine': '요리', 'places.details.fact.openingHours': '영업시간', 'places.details.fact.menu': '메뉴', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index d205dcd75..08c103399 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Maak eerst het beschrijvingsveld leeg', 'places.details.facts': 'Goed om te weten', 'places.details.nothing': 'Niets gevonden voor deze plaats.', + 'places.details.fact.rating': 'Beoordeling', 'places.details.fact.cuisine': 'Keuken', 'places.details.fact.openingHours': 'Openingstijden', 'places.details.fact.menu': 'Menukaart', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index 0888adad8..f50e9881e 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -88,6 +88,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Najpierw wyczyść pole opisu', 'places.details.facts': 'Warto wiedzieć', 'places.details.nothing': 'Nic nie znaleziono dla tego miejsca.', + 'places.details.fact.rating': 'Ocena', 'places.details.fact.cuisine': 'Kuchnia', 'places.details.fact.openingHours': 'Godziny otwarcia', 'places.details.fact.menu': 'Menu', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index 18b1f128b..31b979f8a 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Сначала очистите поле описания', 'places.details.facts': 'Полезно знать', 'places.details.nothing': 'Для этого места ничего не найдено.', + 'places.details.fact.rating': 'Рейтинг', 'places.details.fact.cuisine': 'Кухня', 'places.details.fact.openingHours': 'Часы работы', 'places.details.fact.menu': 'Меню', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index 9dbb01bde..123312d59 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Töm beskrivningsfältet först', 'places.details.facts': 'Bra att veta', 'places.details.nothing': 'Inget hittades för den här platsen.', + 'places.details.fact.rating': 'Betyg', 'places.details.fact.cuisine': 'Kök', 'places.details.fact.openingHours': 'Öppettider', 'places.details.fact.menu': 'Meny', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index 892ecfd00..d79d2ba24 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -99,6 +99,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Önce açıklama alanını boşalt', 'places.details.facts': 'Bilmekte fayda var', 'places.details.nothing': 'Bu yer için bir şey bulunamadı.', + 'places.details.fact.rating': 'Puan', 'places.details.fact.cuisine': 'Mutfak', 'places.details.fact.openingHours': 'Çalışma saatleri', 'places.details.fact.menu': 'Menü', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index 043f095f9..6e78374bb 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Спершу очистіть поле опису', 'places.details.facts': 'Корисно знати', 'places.details.nothing': 'Для цього місця нічого не знайдено.', + 'places.details.fact.rating': 'Рейтинг', 'places.details.fact.cuisine': 'Кухня', 'places.details.fact.openingHours': 'Години роботи', 'places.details.fact.menu': 'Меню', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index deb99e9bd..5f6c3e134 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -97,6 +97,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': 'Hãy xóa trống ô mô tả trước', 'places.details.facts': 'Thông tin hữu ích', 'places.details.nothing': 'Không tìm thấy gì cho địa điểm này.', + 'places.details.fact.rating': 'Đánh giá', 'places.details.fact.cuisine': 'Ẩm thực', 'places.details.fact.openingHours': 'Giờ mở cửa', 'places.details.fact.menu': 'Thực đơn', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index 36533e35c..6495b1f55 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -95,6 +95,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': '請先清空描述欄位', 'places.details.facts': '值得一看', 'places.details.nothing': '找不到此地點的資訊。', + 'places.details.fact.rating': '評分', 'places.details.fact.cuisine': '菜系', 'places.details.fact.openingHours': '營業時間', 'places.details.fact.menu': '菜單', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index d8a877648..6d3b6c3b2 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -95,6 +95,7 @@ const places: TranslationStrings = { 'places.details.adoptBlocked': '请先清空描述字段', 'places.details.facts': '值得一看', 'places.details.nothing': '未找到该地点的信息。', + 'places.details.fact.rating': '评分', 'places.details.fact.cuisine': '菜系', 'places.details.fact.openingHours': '营业时间', 'places.details.fact.menu': '菜单', diff --git a/shared/src/maps/maps.schema.ts b/shared/src/maps/maps.schema.ts index aae7a6ca2..97f00e6c0 100644 --- a/shared/src/maps/maps.schema.ts +++ b/shared/src/maps/maps.schema.ts @@ -147,6 +147,7 @@ export type PlaceDescription = z.infer; * `kind` is translated client-side; `value` is provider data and stays as-is. */ export const placeFactKindSchema = z.enum([ + 'rating', 'cuisine', 'openingHours', 'menu', @@ -176,6 +177,16 @@ export const mapsPlaceEnrichmentRequestSchema = z.object({ /** Used to resolve a Wikipedia article when the place carries no wiki tag. */ name: z.string().min(1).max(300), lang: z.string().max(35).optional(), + /** + * The place record the client already holds from picking the search result. + * + * Enrichment needs the same OSM tags that lookup returned, and fetching them + * again is not cheap: an Overpass lookup for a large relation was measured at + * 12.8 seconds. Passing them along turns a second slow round trip into none. + * Only the tags are read, and the wiki tag is re-validated before use, so a + * doctored payload can at worst mislead the user who sent it. + */ + details: z.record(z.string(), z.unknown()).optional(), }); export type MapsPlaceEnrichmentRequest = z.infer;