openvibers/openvibe.tours Commits

A city's places start with the ones a visitor can use (#46)

OP OpenVibers committed 11 days ago · 49c8aa0e1d · 3 files, +16 −3

A city page listed its places by kind, so Chongqing's opened on an artwork with a Chinese-only
name. They are ordered by a picture, a description and a name the reader can read first, then by
kind and name (the page shows 60). A row's second line leaves out the kind when the name ends with
it ("Bose museum" was followed by "museum"), and its facts join without a stray separator.

Changes

ddiff --git a/server/store.js b/server/store.js
iindex 8fdc4fd..f213f32 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -149,7 +149,11 @@ function createStore(s, { config = {} } = {}) {
async function cityPlaces(cityId, { limit = 60 } = {}) {
const rows = await db.many(`SELECT p.*, c.slug AS city_slug, c.name AS city_name
FROM tours_places p JOIN tours_cities c ON c.id = p.city_id
-- WHERE p.city_id = $1 ORDER BY p.kind, p.name LIMIT $2`, [String(cityId), Math.min(200, Math.max(1, int(limit, 60)))]);
++ WHERE p.city_id = $1
++ -- The places a visitor can use first: a picture, a description, a name they can read; then by kind and name
++ -- (by kind alone, Chongqing's list opened on an artwork with a Chinese-only name, 2026-11-02).
++ ORDER BY (p.image_url IS NOT NULL) DESC, (p.description IS NOT NULL) DESC,
++ (p.name_en IS NOT NULL OR p.name ~ '[A-Za-z]') DESC, p.kind, p.name LIMIT $2`, [String(cityId), Math.min(200, Math.max(1, int(limit, 60)))]);
return rows.map(wirePlace);
}
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 1e7d30f..2c6cb0e 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -74,6 +74,8 @@ const cityCard = (c) => kit.card({
const placeName = (p) => (p && p.name_en) || (p && p.name) || '';
/** The place's own name when the English one is shown instead (洪崖洞 beside Hongya Cave), else ''. */
const localName = (p) => (p && p.name_en && p.name && p.name !== p.name_en ? p.name : '');
++/** A place's kind for its row, unless its name already ends with it ("Bose museum" was followed by "museum"). */
++const kindUnlessNamed = (p) => { const k = String(p.kind || '').replace(/_/g, ' '); return k && !placeName(p).toLowerCase().endsWith(k.toLowerCase()) ? k : ''; };
const placeCard = (p) => kit.card({
href: `/place/${p.id}`,
@@@ -248,7 +250,7 @@ ${img ? `<figure class="tr-city-img"><img src="${esc(img)}" alt="${esc(city.name
</header>
${routes.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Self-guided routes</h2><span class="tr-muted">generated from Wikivoyage's See list</span></div><ol class="tr-route-list">${routes.map((r) => `<li><a href="/routes/${esc(r.id)}"><strong>${esc(r.title)}</strong><span class="tr-muted">${Number(r.stop_count)} stops · ${km(r.distance_m)} · ${Number(r.duration_min)} min</span></a></li>`).join('')}</ol></section>` : ''}
<section class="tr-sec" id="tr-city-places"><div class="tr-sec-head"><h2>Places</h2><a class="tr-more" href="https://www.openstreetmap.org/?mlat=${city.lat}&mlon=${city.lon}#map=13/${city.lat}/${city.lon}" rel="noopener">OpenStreetMap</a></div>
--${places.length ? `<ol class="tr-place-list">${places.map((p) => `<li class="tr-place-row"><a href="/place/${esc(p.id)}"><strong>${esc(placeName(p))}</strong><span class="tr-muted">${localName(p) ? `${esc(localName(p))} · ` : ''}${esc(String(p.kind || '').replace(/_/g, ' '))}${p.address ? ` · ${esc(p.address)}` : ''}${p.wheelchair === 'yes' ? ' · step-free' : ''}${p.fee === 'no' ? ' · free' : ''}</span></a></li>`).join('')}</ol>` : empty('No OpenStreetMap place was stored for this city yet. The places job fills it.', '/cities', 'Another city')}
++${places.length ? `<ol class="tr-place-list">${places.map((p) => `<li class="tr-place-row"><a href="/place/${esc(p.id)}"><strong>${esc(placeName(p))}</strong><span class="tr-muted">${esc([localName(p), kindUnlessNamed(p), p.address, p.wheelchair === 'yes' ? 'step-free' : '', p.fee === 'no' ? 'free' : ''].filter(Boolean).join(' · '))}</span></a></li>`).join('')}</ol>` : empty('No OpenStreetMap place was stored for this city yet. The places job fills it.', '/cities', 'Another city')}
<p class="tr-fine">${SOURCE.osm}</p></section>
${live.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Live tours and recordings</h2><a class="tr-more" href="/live">All live</a></div><ol class="tr-live-list">${live.map((l) => `<li><a href="${esc(l.url)}" rel="noopener"><strong>${esc(l.title)}</strong><span class="tr-muted">${esc(l.kind === 'stream' ? 'Live now' : l.kind === 'clip' ? 'Clip' : 'Recording')} · ${esc(l.display_name || l.username)}${l.city_name ? ` · ${esc(l.city_name)}` : ''}${l.kind === 'stream' ? ` · ${Number(l.viewer_count)} watching` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Live's public stream list · fetched ${when(live[0].fetched_at)}</p></section>` : ''}
${stays.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Stays</h2><a class="tr-more" href="/stays">All stays</a></div><ol class="tr-stay-list">${stays.map((s0) => `<li><a href="${esc(s0.url)}" rel="noopener"><strong>${esc(s0.title)}</strong><span class="tr-muted">${esc(s0.kind)} · ${money(s0.price, s0.currency)} / ${esc(s0.period)}${s0.neighbourhood ? ` · ${esc(s0.neighbourhood)}` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Rent's public listings · never a payment on Tours.</p></section>` : ''}
@@@ -443,4 +445,4 @@ function notFound() {
return `<div class="tr-wrap tr-narrow tr-page-pad" id="tr-not-found"><p class="tr-kicker">404</p><h1>Not here</h1><p class="tr-lede">That city, place, route or trip does not exist, or it is a private trip.</p><p><a class="tr-btn" href="/">Go home</a> <a class="tr-btn tr-btn-quiet" href="/cities">Browse cities</a></p></div>`;
}
--module.exports = { placeName, localName, HOME_FAQ, safeImage, home, citiesPage, cityPage, placePage, routePage, livePage, staysPage, flightsPage, signIn, tripNew, tripsPage, tripPage, developersPage, notFound, placeOfDayCard, heroMotif, miniMap, SOURCE };
++module.exports = { placeName, localName, kindUnlessNamed, HOME_FAQ, safeImage, home, citiesPage, cityPage, placePage, routePage, livePage, staysPage, flightsPage, signIn, tripNew, tripsPage, tripPage, developersPage, notFound, placeOfDayCard, heroMotif, miniMap, SOURCE };
ddiff --git a/test/place-names.test.js b/test/place-names.test.js
iindex 091bed7..b6612c6 100644
---- a/test/place-names.test.js
++++ b/test/place-names.test.js
@@@ -42,6 +42,13 @@ const node = (tags) => ({ type: 'node', id: 9001, lat: 29.56, lon: 106.58, tags:
assert.match(r.text, /<strong>Hongya Cave<\/strong><span class="tr-muted">洪崖洞 · attraction/);
});
++ await check('a row leaves out a kind its name already says, and the facts join without a stray separator', async () => {
++ const views = require('../server/views');
++ assert.strictEqual(views.kindUnlessNamed({ name: 'Bose museum', kind: 'museum' }), '');
++ assert.strictEqual(views.kindUnlessNamed({ name: 'Old Tower', kind: 'attraction' }), 'attraction');
++ assert.strictEqual(views.kindUnlessNamed({ name: '马迭尔宾馆', name_en: 'Modern Hotel', kind: 'hotel' }), '');
++ });
++
await check('the Search document is titled with both names, and the sweep sends it', async () => {
assert.strictEqual(describePlace({ name: '洪崖洞', name_en: 'Hongya Cave', kind: 'attraction', city_name: 'Chongqing', country: 'China' }).title, 'Hongya Cave (洪崖洞)');
assert.strictEqual(describePlace({ name: 'Old Tower', name_en: null, kind: 'attraction', city_name: 'X', country: 'Y' }).title, 'Old Tower');