feat(places): opening hours from the index, with OSM still first

The index can now carry opening hours, from two sources: an
OpenStreetMap layer the service keeps as a separate database file, and
the schema.org data a place publishes on its own site, read in the same
page fetch the description comes from.

OSM stays the first choice where it has them. Measured across seven
countries it covers 27.5 percent of gastronomy, and an OSM entry
describes this exact object and gets corrected by people who walked past
it, where a chain's website often carries one set of hours for every
branch. The rest is what the operator publishes, which measured 8.3
percent on the same kind of place.

Both go through the same expansion into weekday lines, so a card reading
`opening_hours` gets one shape and not two.

Five maps.service cases went red when search started answering from the
index ahead of Google; they are about the shape of the Google request,
so they now switch the index off first.
This commit is contained in:
Maurice
2026-09-06 01:05:21 +02:00
committed by Maurice
parent 87a765b763
commit bac85cb98a
4 changed files with 166 additions and 8 deletions
+32 -7
View File
@@ -1833,11 +1833,10 @@ export class MapsService {
const found = await trekPlacesById(placeId.slice('gers:'.length)).catch(() => null);
if (!found) return { place: null };
// What the index does not have, the free sources still do. The index
// carries no opening hours yet and never carries cuisine, wheelchair
// access or a menu link; OpenStreetMap has all of them for the same
// building. Without this the hours a user saw while adding the place
// disappeared from its card afterwards, which reads like data loss.
// What the index does not have, the free sources still do: cuisine,
// wheelchair access, a menu link. OpenStreetMap has all of them for the
// same building, and without this the details a user saw while adding the
// place disappeared from its card afterwards, which reads like data loss.
//
// Matched by name and coordinate, with the same two gates
// resolveOsmIdentity applies everywhere: within range, and sharing a
@@ -1849,16 +1848,42 @@ export class MapsService {
}).catch(() => null);
const record = toPlaceRecord(found);
if (!osm) return { place: record };
// Hours the index read off the operator's own site, run through the same
// expansion OSM's go through — the client reads a list of weekday lines,
// not the raw syntax, and handing it two shapes for one field would be a
// bug on every card that shows it.
//
// Measured across seven countries, OpenStreetMap has hours for 27.5
// percent of gastronomy; this covers part of the rest. It is the
// fallback, not the first choice: an OSM entry describes this exact
// object and gets corrected by people who walked past, where a chain's
// website often carries one set of hours for every branch.
const fromSite =
typeof found.hours?.osm === 'string' ? buildOsmDetails({ opening_hours: found.hours.osm }, '', '') : null;
if (!osm) {
return {
place: fromSite?.opening_hours
? {
...record,
opening_hours: fromSite.opening_hours,
open_now: fromSite.open_now,
opening_periods: fromSite.opening_periods,
}
: record,
};
}
const osmDetails = buildOsmDetails(osm.tags, '', '');
const hoursFrom = osmDetails.opening_hours ? osmDetails : fromSite;
return {
place: {
// Index first: its name, coordinate and contact details are the ones
// the user picked. OSM only fills what is still missing.
...osmDetails,
...record,
opening_hours: osmDetails.opening_hours ?? null,
opening_hours: hoursFrom?.opening_hours ?? null,
open_now: hoursFrom?.open_now ?? null,
opening_periods: hoursFrom?.opening_periods ?? null,
website: record.website ?? osmDetails.website ?? null,
phone: record.phone ?? osmDetails.phone ?? null,
osm_id: placeId,
+9 -1
View File
@@ -49,7 +49,15 @@ export interface TrekPlace {
};
brand: { name: string | null; wikidata: string | null } | null;
source: string;
hours: unknown | null;
/**
* Opening hours in OSM syntax, plus where they came from. The index carries
* them for a small share of places; the rest are read from the schema.org
* data the operator publishes on their own site, in the same page fetch the
* description comes from.
*/
hours: { osm: string; source: string | null; sourceUrl: string | null } | null;
/** Quoted from the place's own site, when it publishes one for machines. */
description?: { text: string; source: string | null; sourceUrl: string | null } | null;
score?: number;
}