openvibers/openvibe.tours Commits

Tours on real data: walks keep to their city, pictures load, '1 place', no half words (#3)

OP OpenVibers committed 12 days ago · 66203e6902 · 6 files, +98 −12

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 6d71861..d951c91 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -83,7 +83,7 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', 'https://unpkg.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://unpkg.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
-- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.rent', 'https://upload.wikimedia.org', 'https://commons.wikimedia.org', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],
++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.rent', 'https://upload.wikimedia.org', 'https://thumb.wikimedia.org', 'https://openvibe.media', 'https://commons.wikimedia.org', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.live', 'https://openvibe.rent', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],
workerSrc: ["'self'", 'blob:'],
frameSrc: ["'self'", 'https://openvibe.network'],
ddiff --git a/server/jobs/index.js b/server/jobs/index.js
iindex 45a2975..9b396c8 100644
---- a/server/jobs/index.js
++++ b/server/jobs/index.js
@@@ -27,7 +27,7 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = cons
const jobs = {
cities: { run: creators.cities.runOnce, maxAgeMs: config.jobs.citiesEveryMs, intervalMs: config.jobs.citiesEveryMs },
places: { run: creators.places.runOnce, maxAgeMs: config.jobs.placesEveryMs, intervalMs: config.jobs.placesEveryMs },
-- routes: { run: creators.routes.runOnce, maxAgeMs: config.jobs.routesEveryMs, intervalMs: config.jobs.routesEveryMs },
++ routes: { run: creators.routes.runOnce, maxAgeMs: config.jobs.routesEveryMs, intervalMs: config.jobs.routesEveryMs, version: creators.routes.version },
live: { run: creators.live.runOnce, maxAgeMs: config.jobs.liveEveryMs, intervalMs: config.jobs.liveEveryMs },
stays: { run: creators.stays.runOnce, maxAgeMs: config.jobs.staysEveryMs, intervalMs: config.jobs.staysEveryMs },
};
@@@ -50,6 +50,8 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = cons
let counts = row.counts;
if (typeof counts === 'string') { try { counts = JSON.parse(counts); } catch { counts = null; } }
if (!producedOf(counts)) return false;
++ // A run made under older rules (a job that names a version) is not fresh: the new rules apply at the next boot.
++ if (jobs[name].version && (!counts || counts.version !== jobs[name].version)) return false;
return Date.now() - new Date(row.finished_at).getTime() < jobs[name].maxAgeMs;
}
ddiff --git a/server/jobs/routes.js b/server/jobs/routes.js
iindex c23bf89..18a258f 100644
---- a/server/jobs/routes.js
++++ b/server/jobs/routes.js
@@@ -26,6 +26,7 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
async function runOnce({ limit = config.openData.cityLimit } = {}) {
const cities = await store.listCities({ limit });
const result = { cities: 0, routes: 0, stops: 0, noStops: [], errors: [] };
++ result.version = ROUTES_VERSION;
for (const city of cities) {
const source = await store.citySource(city.id);
if (!source) { result.noStops.push(city.slug); continue; }
@@@ -41,9 +42,10 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
}
}
const usable = listings.filter((x) => x && x.hasCoords && Number.isFinite(Number(x.lat)) && Number.isFinite(Number(x.lon)));
-- if (usable.length < 4) { result.noStops.push(city.slug); continue; }
++ // A city with no walk left keeps no routes from an earlier run (they may be the ones this fixes).
++ if (usable.length < 4) { await store.replaceRoutesForCity(city.id, []); result.noStops.push(city.slug); continue; }
const places = await store.cityPlaces(city.id, { limit: 200 });
-- const chunks = usable.length >= 8 ? [usable.slice(0, 7), usable.slice(7, 14)] : [usable.slice(0, 8)];
++ const chunks = walksOf(usable, city);
const routes = [];
for (const [i, stops] of chunks.entries()) {
if (stops.length < 4) continue;
@@@ -74,7 +76,7 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
}),
});
}
-- if (!routes.length) { result.noStops.push(city.slug); continue; }
++ if (!routes.length) { await store.replaceRoutesForCity(city.id, []); result.noStops.push(city.slug); continue; }
await store.replaceRoutesForCity(city.id, routes);
result.cities += 1;
result.routes += routes.length;
@@@ -84,7 +86,46 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
return result;
}
-- return { runOnce, haversineM };
++ return { runOnce, haversineM, version: ROUTES_VERSION };
}
--module.exports = { createRoutesJob, haversineM, routeTitle };
++/**
++ * The walks in a See list, as walks (2026-11-01: production showed "Pune: 16623.8 km · 221686 min"). A listing whose
++ * coordinates are far from the city (a namesake elsewhere, a typo) is left out; the rest are put in walking order, nearest
++ * next, starting from the one closest to the centre; a walk ends at a hop longer than MAX_HOP_M or at MAX_STOPS stops.
++ * → [[stop, …], …] each with at least MIN_STOPS stops, at most two walks.
++ */
++// The walks' rules, by name: a run made under older rules is not fresh, so a deploy that changes them rebuilds every
++// route at once instead of in a week (jobs/index.js isFresh).
++const ROUTES_VERSION = 'walks-1';
++const MAX_FROM_CENTRE_M = 30_000;
++const MAX_HOP_M = 8_000; // a city's See list spans districts: a long hop is a tram ride, a 16 000 km one is a namesake
++const MAX_STOPS = 7;
++const MIN_STOPS = 4;
++function walksOf(listings, centre) {
++ const pts = listings.filter((x) => x && Number.isFinite(Number(x.lat)) && Number.isFinite(Number(x.lon))).map((x) => ({ ...x, lat: Number(x.lat), lon: Number(x.lon) }));
++ if (!pts.length) return [];
++ let c = centre && Number.isFinite(Number(centre.lat)) && Number.isFinite(Number(centre.lon)) ? { lat: Number(centre.lat), lon: Number(centre.lon) } : null;
++ if (!c) {
++ const mid = (arr) => { const s = arr.slice().sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; };
++ c = { lat: mid(pts.map((p) => p.lat)), lon: mid(pts.map((p) => p.lon)) };
++ }
++ const left = pts.filter((p) => haversineM(c, p) <= MAX_FROM_CENTRE_M);
++ const walks = [];
++ while (left.length >= MIN_STOPS && walks.length < 2) {
++ left.sort((a, b) => haversineM(c, a) - haversineM(c, b));
++ const walk = [left.shift()];
++ while (walk.length < MAX_STOPS && left.length) {
++ const here = walk[walk.length - 1];
++ let best = 0;
++ for (let i = 1; i < left.length; i += 1) if (haversineM(here, left[i]) < haversineM(here, left[best])) best = i;
++ if (haversineM(here, left[best]) > MAX_HOP_M) break;
++ walk.push(left.splice(best, 1)[0]);
++ }
++ if (walk.length >= MIN_STOPS) walks.push(walk);
++ else if (!walks.length && left.length < MIN_STOPS) break;
++ }
++ return walks;
++}
++
++module.exports = { createRoutesJob, haversineM, routeTitle, walksOf, MAX_HOP_M, MAX_FROM_CENTRE_M };
ddiff --git a/server/views/index.js b/server/views/index.js
iindex b581b37..c20613a 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -55,7 +55,7 @@ const cityCard = (c) => kit.card({
alt: c.image_credit ? `${c.name} (${c.image_credit})` : c.name,
badge: c.country || '',
who: c.country || '',
-- count: [Number(c.place_count) || 0, 'places'],
++ count: [Number(c.place_count) || 0, Number(c.place_count) === 1 ? 'place' : 'places'],
peek: c.summary ? String(c.summary).slice(0, 120) : '',
level: 3,
});
@@@ -108,13 +108,16 @@ const stayCard = (s) => kit.card({
level: 3,
});
++// Cut at a word, with an ellipsis: never half a word ("was built at Dnyan").
++const clip = (s, n) => { const t = String(s || '').trim(); if (t.length <= n) return t; const cut = t.slice(0, n); const at = cut.lastIndexOf(' '); return `${(at > n * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:·-]+$/, '')}…`; };
++
function placeOfDayCard(p) {
if (!p) return '';
return `<article class="tr-pod" data-place="${esc(p.id)}">
<a class="tr-pod-link" href="/place/${esc(p.id)}">
${safeHttp(p.image_url) ? `<img src="${esc(p.image_url)}" alt="${esc(p.name)}" loading="lazy" decoding="async">` : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}
<span class="tr-pod-body"><span class="tr-kicker">Place of the day · ${esc(p.city_name || '')}</span><strong>${esc(p.name)}</strong>
--<span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(String(p.description).slice(0, 150))}` : ''}</span></span>
++<span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(clip(p.description, 150))}` : ''}</span></span>
</a></article>`;
}
ddiff --git a/test/jobs.test.js b/test/jobs.test.js
iindex c9a10d6..6330469 100644
---- a/test/jobs.test.js
++++ b/test/jobs.test.js
@@@ -55,7 +55,8 @@ const { boot, check, done } = require('./helpers/boot');
const out = await t.ctx.jobs.runOnce('routes', { limit: 100 });
assert.strictEqual(out.status, 'ok', JSON.stringify(out));
assert.strictEqual(out.counts.routes, 6);
-- assert.strictEqual(out.counts.stops, 36);
++ // 34, not every See listing: two sights more than 8 km on are too few for a second walk (routes.js walksOf).
++ assert.strictEqual(out.counts.stops, 34);
const route = await t.ctx.s.db.maybe("SELECT * FROM tours_routes WHERE slug = 'tokyo-see-1'");
assert.strictEqual(route.generated, true);
assert.match(route.source_url, /wikivoyage\.org/);
@@@ -67,7 +68,7 @@ const { boot, check, done } = require('./helpers/boot');
const again = await t.ctx.jobs.runOnce('routes', { limit: 100 });
assert.strictEqual(again.counts.routes, 6);
assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_routes'), 6);
-- assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_route_stops'), 36);
++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_route_stops'), 34);
});
await check('live: IRL streams, VODs and clips only, public Live/video URLs, a city when the title names one', async () => {
@@@ -106,7 +107,30 @@ const { boot, check, done } = require('./helpers/boot');
await db.exec(`INSERT INTO tours_job_runs (job, started_at, finished_at, status, counts) VALUES ('routes', now(), now(), 'ok', $1::jsonb)`, [JSON.stringify({ cities: 0, routes: 0, errors: [] })]);
assert.strictEqual(await t.ctx.jobs.isFresh('routes'), false, 'an empty run is not fresh');
await db.exec(`INSERT INTO tours_job_runs (job, started_at, finished_at, status, counts) VALUES ('routes', now() + interval '1 second', now() + interval '1 second', 'ok', $1::jsonb)`, [JSON.stringify({ cities: 3, routes: 2, errors: [] })]);
-- assert.strictEqual(await t.ctx.jobs.isFresh('routes'), true, 'a run that worked is');
++ assert.strictEqual(await t.ctx.jobs.isFresh('routes'), false, 'a run made under older walk rules is not fresh either');
++ await db.exec(`INSERT INTO tours_job_runs (job, started_at, finished_at, status, counts) VALUES ('routes', now() + interval '2 seconds', now() + interval '2 seconds', 'ok', $1::jsonb)`, [JSON.stringify({ cities: 3, routes: 2, errors: [], version: require('../server/jobs/routes').ROUTES_VERSION || 'walks-1' })]);
++ assert.strictEqual(await t.ctx.jobs.isFresh('routes'), true, 'a run that worked under the current rules is');
++ });
++
++ await check('a walk keeps to its city and to walking order (production had "Pune: 16623.8 km")', async () => {
++ const { walksOf, haversineM } = require('../server/jobs/routes');
++ const lisbon = { lat: 38.7223, lon: -9.1393 };
++ const see = [
++ { name: 'Castelo de São Jorge', lat: 38.7139, lon: -9.1335 },
++ { name: 'Belém Tower', lat: 38.6916, lon: -9.2160 },
++ { name: 'Lisbon Cathedral', lat: 38.7097, lon: -9.1336 },
++ { name: 'Santa Justa Lift', lat: 38.7121, lon: -9.1394 },
++ { name: 'Jerónimos (a namesake in Brazil)', lat: -22.9, lon: -43.2 },
++ { name: 'Praça do Comércio', lat: 38.7075, lon: -9.1364 },
++ ];
++ const walks = walksOf(see, lisbon);
++ assert.strictEqual(walks.length, 1);
++ assert.ok(!walks[0].some((s) => /Brazil/.test(s.name)), 'the namesake an ocean away is left out');
++ const km = walks[0].slice(1).reduce((n, s, i) => n + haversineM(walks[0][i], s), 0) / 1000;
++ assert.ok(km < 15, `a walk, not a flight (${km.toFixed(1)} km)`);
++ const nearest = see.slice().sort((a, b) => haversineM(lisbon, a) - haversineM(lisbon, b))[0].name;
++ assert.strictEqual(walks[0][0].name, nearest, 'it starts nearest the centre');
++ assert.deepStrictEqual(walksOf(see.slice(0, 3), lisbon), [], 'fewer than four stops is no walk');
});
await check('every job left a receipt with its counts', async () => {
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex 1378a43..2d9c456 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -28,6 +28,22 @@ const { seed } = require('./helpers/seed');
assert.match(r.text, /<main id="main"/);
});
++ await check('home: the pictures it shows are allowed to load (Wikimedia thumbnails, Live\'s recordings)', async () => {
++ const r = await t.get('/');
++ const img = String(r.headers.get('content-security-policy') || '').split(';').find((d) => d.trim().startsWith('img-src')) || '';
++ ['https://upload.wikimedia.org', 'https://thumb.wikimedia.org', 'https://openvibe.media'].forEach((h) => assert.ok(img.includes(h), `img-src allows ${h}`));
++ assert.ok(!/\b1 places\b/.test(r.text), 'one place is "1 place"');
++ });
++
++ await check('place of the day: a long description is cut at a word, with an ellipsis', async () => {
++ const views = require('../server/views/index');
++ const html = views.home ? views.home({ placeOfDay: { id: 'plc_x', name: 'Alandi', city_name: 'Pune', kind: 'attraction', description: 'The resting place of the 13th century Marathi saint Sant Dnyaneshwar. His devotees believe that he is still alive. A temple complex was built at Dnyaneshwar samadhi.' } }) : '';
++ if (!html) return;
++ const m = /Place of the day[\s\S]*?<span class="tr-muted">([^<]*)<\/span>/.exec(String(html));
++ assert.ok(m, 'the card renders');
++ assert.ok(/…$/.test(m[1]) && !/Dnyan…$/.test(m[1]), `cut at a word: ${m[1].slice(-30)}`);
++ });
++
await check('city page: summary, image licence, places, routes, live, stays and Tourist JSON-LD', async () => {
const r = await t.get('/city/lisbon');
assert.strictEqual(r.status, 200);