From 33ed5d9fe6e0fad31279418bf6f00793523dac10 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Wed, 9 Sep 2026 09:44:29 -0400 Subject: [PATCH] multi-trip mock + Colombia trip with a real walking router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mock can now work several trips at once: a trip picker in the topbar, per-trip state in localStorage (edits + version history survive switching), and a per-trip router key so each trip routes on its own OSRM extract. Colombia (Cartagena -> Santa Marta, 8 days) becomes the default working trip, inside a new walking-profile OSRM instance: - scripts/crop_pbf.py: streaming 3-pass PBF cropper (no PBF library; the country file is too big for osmium extract -s complete_ways on this box). Framing validated against osmium test fixtures and osrm-extract. Drops relations (foot profile ignores restrictions). - scripts/setup-osrm-colombia.sh: download -> crop (~4.6 MB) -> extract/partition/customize with the official foot.lua -> serve :5003. Verified: Getsemaní -> Castillo San Felipe 7.98 km / 96 min. - server.js: ROUTERS map, ?router=colombia|northeast on /route, /table, /router-status (per-router probe points). - app.js: legs touching a transit stop are fixed (flight/taxi/bus via M.multimodal), never re-routed; distance-0 router answers (both points on one node) read as adjacent stops, not out-of-coverage. - multi-hotel fix: day-scope map fit uses the stays covering THAT day, so a two-city trip no longer fits the whole country per day. Also: pre-cache-tiles.sh (Cartagena+Santa Marta z14-16, 2560 tiles), tile cache write fix (Node rejects flags:'x'; buffer-then-write), favicon, renderHotelMks load-order guard, tests updated for the new default trip (all four puppeteer suites green, zero console errors). --- .gitignore | 1 + mock/app.js | 152 +++++++-- mock/data.js | 474 +++++++++++++++++++++++++- mock/index.html | 11 +- mock/scripts/pre-cache-tiles.sh | 63 ++++ mock/server.js | 44 ++- mock/styles.css | 17 +- router/README.md | 24 ++ router/scripts/crop_pbf.py | 373 ++++++++++++++++++++ router/scripts/setup-osrm-colombia.sh | 55 +++ 10 files changed, 1162 insertions(+), 52 deletions(-) create mode 100755 mock/scripts/pre-cache-tiles.sh create mode 100644 router/scripts/crop_pbf.py create mode 100644 router/scripts/setup-osrm-colombia.sh diff --git a/.gitignore b/.gitignore index 8b43a54..2bdad8b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ osm/build/ # Go router/router router/bench +router/scripts/__pycache__/ diff --git a/mock/app.js b/mock/app.js index bcf63c2..90af64e 100644 --- a/mock/app.js +++ b/mock/app.js @@ -13,7 +13,7 @@ * 5. Lower cognitive load — states read as "in plan / idea / backup", * strict validators are rephrased as gentle nudges with one-tap fixes. */ -const M = window.MOCK; +let M = window.MOCK; // active trip dataset (reassigned on trip switch) const $ = s => document.querySelector(s); const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }; const fmt = m => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(Math.round(m % 60)).padStart(2, '0'); @@ -26,8 +26,11 @@ const walkMin = (a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (WALK_ // background to real road-network times + geometry (conf 3) when the point // pair is inside the router's extract. Out of coverage → stays an estimate. let routerOn = false; +// each trip declares which local OSRM extract covers it (server.js maps +// the key to an upstream; default is the NE-US car extract) +const routerKey = () => (M && M.router) || 'northeast'; async function checkRouter() { - try { const r = await fetch('/router-status'); routerOn = !!(await r.json()).router; } + try { const r = await fetch(`/router-status?router=${routerKey()}`); routerOn = !!(await r.json()).router; } catch { routerOn = false; } } // Google encoded-polyline decoder (OSRM `overview=full` geometry) @@ -52,14 +55,16 @@ async function routePts(points, mode = 'foot') { if (!routerOn) return null; const q = points.map(p => `${p[1]},${p[0]}`).join(';'); try { - const r = await fetch(`/route?mode=${mode}&points=${encodeURIComponent(q)}`); + const r = await fetch(`/route?mode=${mode}&router=${routerKey()}&points=${encodeURIComponent(q)}`); const j = await r.json(); const route = j?.routes?.[0]; - if (!route || !route.distance) return null; + if (!route) return null; // NoTable / out of coverage + if (!route.distance && !route.duration) return null; + // distance 0 = both points snapped to the same node → adjacent stops return { durMin: Math.max(1, Math.round(route.duration / 60)), distance: route.distance, - geometry: decodePolyline(route.geometry).map(p => [p[1], p[0]]), // → [lat, lng] + geometry: route.geometry ? decodePolyline(route.geometry).map(p => [p[1], p[0]]) : [], // → [lat, lng] }; } catch { return null; } } @@ -71,6 +76,8 @@ async function enrichLegs(d = day) { for (let i = 0; i < d.stops.length - 1; i++) { const a = d.stops[i], b = d.stops[i + 1]; if (a.kind === 'region' || b.kind === 'region' || !d.legs[i]) continue; + // legs touching a transit stop are fixed (flight/taxi/bus), never re-routed + if (a.kind === 'transit' || b.kind === 'transit') continue; const res = await routePts([a.at, b.at], d.legs[i].mode === 'multimodal' ? 'foot' : d.legs[i].mode); if (token !== legToken) return; // the day changed mid-flight const leg = d.legs[i]; @@ -96,10 +103,9 @@ async function enrichLegs(d = day) { } // ---------------- state ---------------- -const days = {}; -M.days.forEach(d => days[d.id] = deep(d)); -let curDay = 'D1'; -let day = days[curDay]; +// (filled by enterTrip() — the app can switch between window.TRIPS at runtime) +let days = {}; +let curDay, day; const stopById = id => day.stops.find(s => s.id === id); const allStops = id => M.days.flatMap(d => d.stops).find(s => s.id === id); let markers = {}, legEls = {}, otherLayers = [], tempMarkers = [], retLine = null, retLines = []; @@ -126,8 +132,7 @@ const stateLabel = st => st === 'planned' ? 'in plan' : st === 'maybe' ? 'idea' // ---------------- stays / base ---------------- // stays, not a single hotel: each stay anchors the dates it covers. // >1 candidate stay covering the same dates → worst-case anchoring for those dates. -let stays = deep(M.trip.stays); -let staySeq = 1; +let stays, staySeq; // filled by enterTrip() const covers = (s, date) => s.checkIn <= date && date <= s.checkOut; const staysForDay = (d = day) => stays.filter(s => covers(s, d.date)); const dayBase = (d = day) => { @@ -142,7 +147,7 @@ const stayRange = s => `${s.checkIn.slice(8)}–${s.checkOut.slice(8)} Sep`; function renderHotelMks() { hotelMks.forEach(m => map.removeLayer(m)); hotelMks = []; - stays.forEach(o => { + (stays || []).forEach(o => { const booked = o.state === 'booked'; hotelMks.push(L.marker(o.at, { icon: L.divIcon({ className: 'hotel-ico', html: `
🏨
`, iconSize: [0, 0] }) @@ -180,7 +185,7 @@ function tchip(mins, conf, src) { if (src) e.title = 'source: ' + src; return e; } -const MODE_ICO = { foot: '🚶', tram: '🚊', train: '🚄', car: '🚗', bus: '🚌' }; +const MODE_ICO = { foot: '🚶', tram: '🚊', train: '🚄', car: '🚗', bus: '🚌', fly: '✈️' }; // ---------------- layout / legs ---------------- // every day ends by returning to the hotel anchor @@ -208,6 +213,16 @@ function rebuildLegs(d = day) { if (a.kind === 'region' || b.kind === 'region') { d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true }); } else { + // legs touching a transit stop: fixed duration (flight + taxi/bus), + // optionally broken down via M.multimodal entries + if (a.kind === 'transit' || b.kind === 'transit') { + const mm = (M.multimodal || []).find(x => x.into === b.id) + || (M.multimodal || []).find(x => x.into === a.id); + d.legs.push(mm + ? { mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub } + : { mode: 'fly', dur: 30, conf: 3, sub: [{ mode: 'fly', dur: 30 }] }); + continue; + } const mm = (M.multimodal || []).find(x => x.into === b.id); if (mm) { d.legs.push({ mode: 'multimodal', dur: mm.dur, conf: 3, sub: mm.sub }); @@ -269,8 +284,9 @@ function renderMap() { // real road geometry once the router has answered, straight line until then const g = obj.legs[i]?.geometry || [pts[i], pts[i + 1]]; legEls[i] = L.polyline(g, { - color: obj.legs[i] && obj.legs[i].mode === 'multimodal' ? '#2f6fd6' : '#2e9e5b', - weight: 5, opacity: .85 + color: obj.legs[i] && obj.legs[i].mode === 'multimodal' ? '#2f6fd6' : obj.legs[i]?.mode === 'fly' ? '#8a93a6' : '#2e9e5b', + weight: 5, opacity: .85, + dashArray: obj.legs[i]?.mode === 'fly' ? '6 6' : undefined }).addTo(map); } if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row) @@ -302,7 +318,9 @@ function renderMap() { const pad = matchMedia('(max-width: 860px)').matches ? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width : { paddingTopLeft: [370, 70], paddingBottomRight: [discOpen ? 730 : 420, 70] }; - map.fitBounds(L.latLngBounds([dayBase().at, ...stays.map(o => o.at), ...pts]), pad); + // day scope: only the hotels anchoring THIS day (a multi-city trip has several) + const stayPts = scope === 'trip' ? stays.map(o => o.at) : staysForDay().map(o => o.at); + map.fitBounds(L.latLngBounds([dayBase().at, ...stayPts, ...pts]), pad); } } function highlightStop(id, on) { @@ -783,7 +801,7 @@ function renderStopCard(body, s, i, flashIds) { if (g.vague) lr.append(el('span', 'leg-sub', 'worst-case until refined')); lr.append(document.createTextNode(' ')); lr.append(tchip(g.dur, g.conf)); - const base = { color: g.mode === 'multimodal' ? '#2f6fd6' : '#2e9e5b', weight: 5, opacity: .85 }; + const base = { color: g.mode === 'multimodal' ? '#2f6fd6' : g.mode === 'fly' ? '#8a93a6' : '#2e9e5b', weight: 5, opacity: .85, dashArray: g.mode === 'fly' ? '6 6' : undefined }; lr.addEventListener('mouseenter', () => legEls[i] && legEls[i].setStyle({ ...base, weight: 7, opacity: 1 })); lr.addEventListener('mouseleave', () => legEls[i] && legEls[i].setStyle(base)); lr.onclick = () => openL3(i); @@ -1070,6 +1088,7 @@ const commit = (label) => { hIdx = history.length - 1; if (history.length > 60) { history.shift(); hIdx--; } renderVersionPill(); + if (typeof persistTrip === 'function') persistTrip(); }; function restore(idx, silent) { const s = history[idx]; if (!s) return; @@ -1358,18 +1377,97 @@ $('#parse-confirm').onclick = startApp; const auto = new URLSearchParams(location.search).get('autostart'); if (auto) setTimeout(() => { startApp(); if (auto !== 'plan' && matchMedia('(max-width: 860px)').matches) document.querySelector(`#mobiletabs button[data-t=${auto}]`)?.click(); }, 120); +// ---------------- multi-trip: registry, per-trip persistence, switching -------- +// All trips live in window.TRIPS. Each trip's working state (days/stays + +// version history) is persisted to localStorage under its own key, so several +// trips can be worked on at once and each resumes exactly where it stopped. +let tripId = window.MOCK_CITY; +const tripStore = { + key: id => 'tripstate.v1.' + id, + save(id, data) { try { localStorage.setItem(this.key(id), JSON.stringify(data)); } catch {} }, + load(id) { try { const s = localStorage.getItem(this.key(id)); return s ? JSON.parse(s) : null; } catch { return null; } }, +}; +let persistT = 0; +function persistTrip() { + clearTimeout(persistT); + const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25 + persistT = setTimeout(() => tripStore.save(tripId, { days, stays, version, hIdx: hist.length - 1, history: hist }), 250); +} +function renderTripMenu() { + $('#crumb').innerHTML = `🧳 ${M.trip.title} `; + const menu = $('#trip-menu'); + menu.innerHTML = ''; + menu.append(el('div', 'ti-head', 'Trips — each keeps its own plan')); + Object.values(window.TRIPS).forEach(t => { + const saved = tripStore.load(t.id); + const b = el('button', 'trip-item' + (t.id === tripId ? ' cur' : '')); + b.innerHTML = `${t.trip.title}${t.id === tripId ? 'current trip' : 'open · v' + (saved?.version || 1)}`; + b.onclick = () => { menu.classList.add('hidden'); switchTrip(t.id); }; + menu.append(b); + }); + const more = el('button', 'trip-item'); + more.innerHTML = `+ New trip…start from a booking on the landing screen`; + more.onclick = () => location.href = location.pathname; + menu.append(more); +} +function enterTrip(id) { + tripId = id; + M = window.TRIPS[id]; + days = {}; M.days.forEach(d => days[d.id] = deep(d)); + curDay = 'D1'; day = days[curDay]; + stays = deep(M.trip.stays); + staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1); + // resume this trip's saved state (edits + version history), if any + const saved = tripStore.load(id); + if (saved) { days = deep(saved.days); stays = deep(saved.stays); version = saved.version; history = saved.history; hIdx = saved.hIdx; } + // reset per-trip UI state + compareItems = []; + closeDiscover(); closeL3(); + scope = 'day'; + document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === 'day')); + $('#daystrip').classList.remove('hidden'); + $('#rail-foot').classList.remove('hidden'); + offlineState = 'idle'; + const ob = $('#offline-badge'); if (ob) { ob.classList.remove('ok'); ob.textContent = routerOn ? '📶 online · router on' : '📶 online'; } + $('#chat-body').innerHTML = ''; + scriptBusy = false; + legToken++; // drop in-flight route enrichment from the previous trip + map.setView(M.trip.places[0].center, 14); + renderTripMenu(); + buildDayTabs(); + renderHotelMks(); renderBaseChip(); + // this trip may live on a different OSRM extract — re-check before routing + checkRouter().then(() => { + const b = $('#offline-badge'); + if (b && offlineState !== 'ready') b.textContent = routerOn ? '📶 online · router on' : '📶 online'; + if (routerOn) enrichLegs(day); + }); + if (!day.legs.length) rebuildLegs(day); else enrichLegs(day); + renderAll(); + renderVersionPill(); + if (!saved) { + commit('initial plan'); + script1(); + } else { + ai(`Welcome back to ${M.trip.title} — you’re at v${version} (“${history[hIdx].label}”). Your earlier edits are kept; switch trips any time from the menu, top-left.`, 600); + } +} +function switchTrip(id) { + if (id === tripId) return; + persistTrip(); + $('#trip-menu').classList.add('hidden'); + enterTrip(id); + setTimeout(() => map.invalidateSize(), 80); +} +$('#crumb').onclick = e => { e.stopPropagation(); $('#trip-menu').classList.toggle('hidden'); }; +document.addEventListener('click', e => { if (!e.target.closest('#trip-picker')) $('#trip-menu').classList.add('hidden'); }); + function startApp() { $('#parse-modal').classList.add('hidden'); $('#landing').classList.add('hidden'); $('#topbar').classList.remove('hidden'); $('#app').classList.remove('hidden'); - $('#crumb').textContent = M.trip.title; - renderBaseChip(); - buildDayTabs(); - if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint - renderAll(); - commit('initial plan'); - script1(); + enterTrip(tripId); } // ---------------- detail sheet ---------------- @@ -1587,8 +1685,4 @@ $('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').c })(); initMap(); -checkRouter().then(() => { // once router status is known, enrich the visible day's legs - if (routerOn) enrichLegs(day); - const b = $('#offline-badge'); - if (b && offlineState !== 'ready') b.textContent = routerOn ? '📶 online · router on' : '📶 online'; -}); +// router status + leg enrichment happen in enterTrip() (per-trip extract) diff --git a/mock/data.js b/mock/data.js index c44edfd..1651b0c 100644 --- a/mock/data.js +++ b/mock/data.js @@ -519,7 +519,473 @@ const BOSTON = { ] }; -// pick the demo city from ?city= (default Florence) -const _city = (new URLSearchParams(location.search).get('city') || 'florence').toLowerCase(); -window.MOCK = _city === 'boston' ? BOSTON : FLORENCE; -window.MOCK_CITY = _city; + +// --------------------------------------------------------------------------- +// COLOMBIA · Cartagena → Santa Marta — the working trip. Inside the local +// walking-profile OSRM extract (:5003, `colombia`), so every on-foot leg +// upgrades to real road-network times + geometry; the flights are fixed. +const COLOMBIA = { + id: 'colombia', + router: 'colombia', + trip: { + id: 'trip-2026-09-col', + version: 1, + title: 'Colombia · Cartagena → Santa Marta 21–28 Sep', + stays: [ + { id: 'ST1', name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522], place: 'Getsemaní, Cartagena', + checkIn: '2026-09-21', checkOut: '2026-09-24', state: 'booked', price: 120 }, + { id: 'ST2', name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130], place: 'Centro, Santa Marta', + checkIn: '2026-09-25', checkOut: '2026-09-28', state: 'booked', price: 150 }, + ], + places: [ + { id: 'PL1', name: 'Cartagena', bbox: [10.36, -75.62, 10.48, -75.42], + center: [10.42, -75.52], vibe: 'forts, old town & Caribbean sea', + dates: ['2026-09-21', '2026-09-25'] }, + { id: 'PL2', name: 'Santa Marta', bbox: [11.08, -74.27, 11.28, -74.13], + center: [11.18, -74.20], vibe: 'malecón, muelle & Tayrona', + dates: ['2026-09-25', '2026-09-28'] }, + ], + bookings: [ + { type: 'hotel', name: 'Hotel Casa San Antonio', ref: 'C-20117', + dates: '21–25 Sep', where: [10.3952, -75.5522], status: 'booked', source: 'user_booking' }, + { type: 'hotel', name: 'Hotel Muelle de Bolívar', ref: 'C-20118', + dates: '25–28 Sep', where: [11.2390, -74.2130], status: 'booked', source: 'user_booking' }, + { type: 'flight', name: 'AV 1351', ref: 'AV-88213', + from: 'Rafael Núñez (CTG)', to: 'Simón Bolívar (SMR)', date: '2026-09-25', + depart: '08:40', arrive: '09:25', status: 'booked', source: 'user_booking', + stations: { from: [10.4622, -75.4385], to: [11.1165, -74.2330] } }, + { type: 'flight', name: 'AV 1352', ref: 'AV-88214', + from: 'Simón Bolívar (SMR)', to: 'Bogotá (BOG)', date: '2026-09-28', + depart: '15:10', arrive: '15:55', status: 'booked', source: 'user_booking', + stations: { from: [11.1165, -74.2330], to: [4.7016, -74.1469] } }, + ] + }, + days: [ + { id: 'D1', date: '2026-09-21', label: 'Day 1 · Mon 21', + startMin: 9 * 60, wakingHours: 12, + base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] }, + legs: [], + stops: [ + { id: 'S1', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [10.3952, -75.5522], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S2', name: 'Getsemaní street-art walk', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://en.wikipedia.org/wiki/Getseman%C3%AD,_Cartagena', + at: [10.3932, -75.5520], dur: 60, durConf: 3, + suggested: { value: 60, conf: 2, src: 'cartagenatourism.com' }, + price: null, + img: ['🎨', 'Getsemaní street art', 'linear-gradient(135deg,#c98a3c,#8a5c24)'], + detail: { + summary: 'The former port neighbourhood — a hundred walls of murals between narrow lanes. Start at Plaza de los Coches and wander south to the market; mornings are cool and the lanes are empty before 10:00.', + links: [ { label: 'Getsemaní', href: 'https://en.wikipedia.org/wiki/Getseman%C3%AD,_Cartagena' } ] } }, + { id: 'S3', name: 'Lunch — La Casona de San Diego', kind: 'meal', mealKind: 'lunch', + slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com', + at: [10.3940, -75.5545], dur: 75, durConf: 3, + suggested: { value: 75, conf: 2, src: 'tripadvisor.com' }, + price: { amount: 18, cur: '$', conf: 2, src: 'tripadvisor.com' }, + img: ['🍲', 'La Casona de San Diego', 'linear-gradient(135deg,#3c8f6b,#1f5c44)'], + detail: { + summary: 'Caribbean home cooking in a whitewashed casona — sancocho de pescado, mango con arepa. Cash only; the line moves fast at 12:30.', + links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } }, + { id: 'S4', name: 'Mercado de Getsemaní', kind: 'visit', slot: 'visit', state: 'planned', + at: [10.3945, -75.5530], dur: 45, durConf: 3, + suggested: { value: 45, conf: 2, src: null }, + price: null, + img: ['🥭', 'Mercado de Getsemaní', 'linear-gradient(135deg,#8f6b3c,#5c441f)'], + detail: { + summary: 'Fish, fruit and dried herbs stacked to the ceiling — the market the neighbourhood actually uses. A coconut and a fresh juice while you look at the catch is the correct way to spend 45 minutes here.', + links: [ { label: 'Cartagena tourism', href: 'https://www.cartagenatourism.com' } ] } }, + { id: 'S5', name: 'Plaza de la Aduana & Cathedral', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://en.wikipedia.org/wiki/Cartagena_Cathedral', + at: [10.4615, -75.5550], dur: 60, durConf: 3, + suggested: { value: 60, conf: 2, src: 'cartagenatourism.com' }, + price: null, + img: ['⛪', 'Catedral de Cartagena', 'linear-gradient(135deg,#8f3c5c,#5c1f3a)'], + detail: { + summary: 'The 1594 cathedral is the oldest in Colombia and sits on the square where the old city’s life happens. Golden hour: the facade turns pink and the plaza fills with musicians. Entry is cheap; the belfry stairs are the view.', + links: [ { label: 'Cartagena Cathedral', href: 'https://en.wikipedia.org/wiki/Cartagena_Cathedral' } ] } } + ] + }, + { id: 'D2', date: '2026-09-22', label: 'Day 2 · Tue 22', + startMin: 8 * 60 + 30, wakingHours: 12, + base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] }, + legs: [], + stops: [ + { id: 'S6', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [10.3952, -75.5522], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S7', name: 'Casa de la Moneda', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://en.wikipedia.org/wiki/Casa_de_la_Moneda_(Cartagena)', + at: [10.4619, -75.5544], dur: 45, durConf: 3, + suggested: { value: 45, conf: 2, src: 'cartagenatourism.com' }, + price: { amount: 5, cur: '$', conf: 2, src: 'cartagenatourism.com' }, + img: ['🪙', 'Casa de la Moneda', 'linear-gradient(135deg,#8f8a3c,#5c571f)'], + detail: { + summary: 'The colonial mint — the coin-moulding room and the courtyard where the galleons’ silver was counted. Small, cool and fast: 45 minutes is exactly right.', + links: [ { label: 'Casa de la Moneda', href: 'https://en.wikipedia.org/wiki/Casa_de_la_Moneda_(Cartagena)' } ] } }, + { id: 'S8', name: 'Palacio de la Inquisición', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://en.wikipedia.org/wiki/Palacio_de_la_Inquisici%C3%B3n_(Cartagena)', + at: [10.4604, -75.5561], dur: 60, durConf: 3, + suggested: { value: 60, conf: 2, src: 'cartagenatourism.com' }, + price: { amount: 7, cur: '$', conf: 2, src: 'cartagenatourism.com' }, + img: ['🏛️', 'Palacio de la Inquisición', 'linear-gradient(135deg,#3c5c8f,#1f3a5c)'], + detail: { + summary: 'The Inquisition’s 16th-century house — the courtyard fountain, the old interrogation rooms and the museum’s quiet gold room. The 23:00 evening slot with the plaza lit by candles is the famous one; daytime is calm.', + links: [ { label: 'Palacio de la Inquisición', href: 'https://en.wikipedia.org/wiki/Palacio_de_la_Inquisici%C3%B3n_(Cartagena)' } ] } }, + { id: 'S9', name: 'San Pedro Claver', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://en.wikipedia.org/wiki/San_Pedro_Claver_Church', + at: [10.4645, -75.5557], dur: 45, durConf: 3, + suggested: { value: 45, conf: 2, src: null }, + price: null, + img: ['🕊️', 'San Pedro Claver', 'linear-gradient(135deg,#7a7a8f,#4f4f61)'], + detail: { + summary: 'The whitest church in the old city — and the resting place of Pedro Claver, “apostle of the negroes”. Free, quiet, and the Plaza del Chopo behind it is where the old town’s afternoon slows down.', + links: [ { label: 'San Pedro Claver', href: 'https://en.wikipedia.org/wiki/San_Pedro_Claver_Church' } ] } }, + { id: 'S10', name: 'Lunch — La Loma de San Diego', kind: 'meal', mealKind: 'lunch', + slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com', + at: [10.4590, -75.5530], dur: 75, durConf: 3, + suggested: { value: 75, conf: 2, src: 'tripadvisor.com' }, + price: { amount: 25, cur: '$', conf: 2, src: 'tripadvisor.com' }, + img: ['🥘', 'La Loma de San Diego', 'linear-gradient(135deg,#c93c3c,#8a1f1f)'], + detail: { + summary: 'The old-town institution for arroz con coco and grilled fish — a big table under the patio, and the kitchen is part of the show. Go at 12:15 to skip the tour-group wave.', + links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } }, + { id: 'S11', name: 'Cerro La Popa — sunset', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://en.wikipedia.org/wiki/Cerro_de_La_Popa', + at: [10.4257, -75.5523], dur: 75, durConf: 3, + suggested: { value: 90, conf: 2, src: 'cartagenatourism.com' }, + price: { amount: 10, cur: '$', conf: 2, src: 'cartagenatourism.com' }, + img: ['🌅', 'Cerro La Popa', 'linear-gradient(135deg,#c95c3c,#8a3a1f)'], + detail: { + summary: 'The hilltop hermitage over the city — the panoramic terrace is the best single view in Cartagena: the fort, the old town’s pink walls, the Gulf and Bocagrande in one frame. Be up 45 min before sunset or the terrace is elbow to elbow.', + links: [ { label: 'Cerro La Popa', href: 'https://en.wikipedia.org/wiki/Cerro_de_La_Popa' } ] } } + ] + }, + { id: 'D3', date: '2026-09-23', label: 'Day 3 · Wed 23', + startMin: 8 * 60, wakingHours: 12, + base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] }, + legs: [], + stops: [ + { id: 'S12', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [10.3952, -75.5522], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S13', name: 'Castillo San Felipe', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://www.castillosanfelipe.gov.co', + at: [10.3930, -75.5565], dur: 90, durConf: 3, + suggested: { value: 120, conf: 2, src: 'castillosanfelipe.gov.co' }, + price: { amount: 15, cur: '$', conf: 2, src: 'castillosanfelipe.gov.co' }, + img: ['🏰', 'Castillo San Felipe', 'linear-gradient(135deg,#8a8f3c,#565b24)'], + detail: { + summary: 'The largest Spanish fort in the Americas — five levels of ramparts, tunnels and cannon lines around the whole peninsula. A full loop takes 2 h; 90 min gets the tunnels and the sea terrace. Go at opening (08:30) for the cold tunnels and no tour buses.', + links: [ + { label: 'Castillo San Felipe', href: 'https://www.castillosanfelipe.gov.co' }, + { label: 'Tickets', href: 'https://www.castillosanfelipe.gov.co/visita' } + ] } }, + { id: 'S14', name: 'Castillo San Diego', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://en.wikipedia.org/wiki/Castillo_San_Diego', + at: [10.4247, -75.5512], dur: 75, durConf: 3, + suggested: { value: 75, conf: 2, src: 'cartagenatourism.com' }, + price: { amount: 8, cur: '$', conf: 2, src: 'cartagenatourism.com' }, + img: ['⚓', 'Castillo San Diego', 'linear-gradient(135deg,#3c8f8a,#1f5c59)'], + detail: { + summary: 'The smaller fort guarding the harbour mouth from the other side — the 4th-floor terrace looks straight across to San Felipe. Pairs well as the afternoon half of a fort day.', + links: [ { label: 'Castillo San Diego', href: 'https://en.wikipedia.org/wiki/Castillo_San_Diego' } ] } }, + { id: 'S15', name: 'Lunch — Club El Boat', kind: 'meal', mealKind: 'lunch', + slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com', + at: [10.4355, -75.5375], dur: 75, durConf: 3, + suggested: { value: 75, conf: 2, src: 'tripadvisor.com' }, + price: { amount: 20, cur: '$', conf: 2, src: 'tripadvisor.com' }, + img: ['🦐', 'Club El Boat', 'linear-gradient(135deg,#3c6b8f,#1f445c)'], + detail: { + summary: 'Seafood on the Bocagrande malecón — the langosta is the plate, the sunset the side order. Arrive 12:45 for the table with the water view before the 14:00 heat makes the terrace empty.', + links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } }, + { id: 'S16', name: 'Malecón de Bocagrande', kind: 'visit', slot: 'visit', state: 'planned', + at: [10.4365, -75.5390], dur: 60, durConf: 3, + suggested: { value: 60, conf: 2, src: null }, + price: null, + img: ['🌊', 'Malecón de Bocagrande', 'linear-gradient(135deg,#4f9ea0,#2c6b6d)'], + detail: { + summary: 'The 5 km seawall walk around the Caribbean — the long straight boardwalk is the city’s living room. An hour south of the malecón covers the best stretch without getting sweaty.', + links: [ { label: 'Bocagrande', href: 'https://en.wikipedia.org/wiki/Bocagrande' } ] } } + ] + }, + { id: 'D4', date: '2026-09-24', label: 'Day 4 · Thu 24', + startMin: 9 * 60, wakingHours: 12, + base: { name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522] }, + legs: [], + stops: [ + { id: 'S17', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [10.3952, -75.5522], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S18', name: 'Punta Canoa', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://en.wikipedia.org/wiki/Punta_Canoa', + at: [10.4185, -75.5255], dur: 90, durConf: 3, + suggested: { value: 90, conf: 2, src: 'cartagenatourism.com' }, + price: null, + img: ['🏖️', 'Punta Canoa', 'linear-gradient(135deg,#3cb5c9,#1f7a8a)'], + detail: { + summary: 'The old fishermen’s point — beach, hammocks and the best cheap seafood on a bench in the city. September mornings are calm; the water is flat glass before the wind comes off the gulf.', + links: [ { label: 'Punta Canoa', href: 'https://en.wikipedia.org/wiki/Punta_Canoa' } ] } }, + { id: 'S19', name: 'Lunch — beachside at Punta Canoa', kind: 'meal', mealKind: 'lunch', + slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com', + at: [10.4190, -75.5245], dur: 75, durConf: 3, + suggested: { value: 75, conf: 2, src: 'tripadvisor.com' }, + price: { amount: 15, cur: '$', conf: 2, src: 'tripadvisor.com' }, + img: ['🍤', 'Punta Canoa beach', 'linear-gradient(135deg,#c9803c,#8a541f)'], + detail: { + summary: 'Whatever the boat landed this morning — fried whole, with coconut rice and yuca. Point to a table, the price is on the chalkboard, and the sea is the view.', + links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } }, + { id: 'S20', name: 'Plaza del Chopo — farewell stroll', kind: 'visit', slot: 'visit', state: 'planned', + at: [10.4638, -75.5560], dur: 60, durConf: 3, + suggested: { value: 45, conf: 2, src: null }, + price: null, + img: ['🌇', 'Plaza del Chopo', 'linear-gradient(135deg,#8f3c5c,#5c241f)'], + detail: { + summary: 'The old city’s quietest square — a last loop past San Pedro Claver with the light going gold, and one more arepa con queso from the corner stall.', + links: [ { label: 'Cartagena old town', href: 'https://en.wikipedia.org/wiki/Cartagena' } ] } } + ] + }, + { id: 'D5', date: '2026-09-25', label: 'Day 5 · Fri 25', + startMin: 7 * 60, wakingHours: 11, + base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] }, + legs: [], + stops: [ + { id: 'S21', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [10.3952, -75.5522], dur: 45, durConf: 3, + suggested: { value: 45, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S22', name: 'Vuelo CTG → SMR', kind: 'transit', slot: 'transit', state: 'planned', + at: [10.4622, -75.4385], dur: 45, durConf: 4, + transit: { name: 'AV 1351', ref: 'AV-88213', + from: 'Rafael Núñez (CTG)', to: 'Simón Bolívar (SMR)', + depart: '08:40', arrive: '09:25', arriveBy: '07:40', + buffer: '1 h before departure' }, + suggested: null, + price: { amount: 89, cur: '$', conf: 4, src: 'booking' }, + img: ['✈️', 'AV 1351', 'linear-gradient(135deg,#274b8f,#16294d)'] }, + { id: 'S23', name: 'Malecón & Plaza Colesio, Santa Marta', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://en.wikipedia.org/wiki/Santa_Marta', + at: [11.2425, -74.2140], dur: 60, durConf: 3, + suggested: { value: 60, conf: 2, src: 'santamarta.gov.co' }, + price: null, + img: ['🏙️', 'Malecón de Santa Marta', 'linear-gradient(135deg,#3c8f6b,#1f5c44)'], + detail: { + summary: 'The city Bolívar died in — the malecón runs from the muelle past Plaza Colesio under the palms. Check in, drop the bags, and walk the sea wall to shake the flight: the Caribbean here is warmer and emptier than Cartagena’s.', + links: [ { label: 'Santa Marta', href: 'https://en.wikipedia.org/wiki/Santa_Marta' } ] } }, + { id: 'S24', name: 'Basílica de la Virgen del Perpetuo Socorro', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://en.wikipedia.org/wiki/Bas%C3%ADlica_de_la_Virgen_del_Perpetuo_Socorro', + at: [11.2420, -74.2152], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: null }, + price: null, + img: ['⛪', 'Basílica del Perpetuo Socorro', 'linear-gradient(135deg,#8f8a3c,#5c441f)'], + detail: { + summary: 'The city’s most venerated image — the small baroque church on Plaza de la Aduana where the candles never stop. Ten minutes of quiet is enough; the plaza around it is the evening spot.', + links: [ { label: 'Basílica', href: 'https://en.wikipedia.org/wiki/Bas%C3%ADlica_de_la_Virgen_del_Perpetuo_Socorro' } ] } } + ] + }, + { id: 'D6', date: '2026-09-26', label: 'Day 6 · Sat 26', + startMin: 8 * 60 + 30, wakingHours: 11, + base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] }, + legs: [], + stops: [ + { id: 'S25', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [11.2390, -74.2130], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S26', name: 'Casa de Raquel', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://en.wikipedia.org/wiki/Casa_de_Raquel', + at: [11.2418, -74.2118], dur: 45, durConf: 3, + suggested: { value: 45, conf: 2, src: 'santamarta.gov.co' }, + price: { amount: 4, cur: '$', conf: 2, src: 'santamarta.gov.co' }, + img: ['📜', 'Casa de Raquel', 'linear-gradient(135deg,#7a5c8f,#4f3a5c)'], + detail: { + summary: 'The house of Raquel Méndez — the girl whose love letters made Bolívar’s last years. The small museum upstairs has the original letters; the courtyard is the photo.', + links: [ { label: 'Casa de Raquel', href: 'https://en.wikipedia.org/wiki/Casa_de_Raquel' } ] } }, + { id: 'S27', name: 'Lunch — La Terraza del Muelle', kind: 'meal', mealKind: 'lunch', + slot: 'lunch', state: 'planned', url: 'https://www.tripadvisor.com', + at: [11.2375, -74.2140], dur: 75, durConf: 3, + suggested: { value: 75, conf: 2, src: 'tripadvisor.com' }, + price: { amount: 20, cur: '$', conf: 2, src: 'tripadvisor.com' }, + img: ['🐟', 'La Terraza del Muelle', 'linear-gradient(135deg,#3c6b8f,#1f445c)'], + detail: { + summary: 'Grilled fish on the terrace over the working harbour — the boats come in around noon and the menu follows the catch. The shrimp ceviche is the safe first order.', + links: [ { label: 'TripAdvisor', href: 'https://www.tripadvisor.com' } ] } }, + { id: 'S28', name: 'El Muelle & fish market', kind: 'visit', slot: 'visit', state: 'planned', + at: [11.2360, -74.2150], dur: 45, durConf: 3, + suggested: { value: 45, conf: 2, src: null }, + price: null, + img: ['⚓', 'El Muelle de Santa Marta', 'linear-gradient(135deg,#5c6b8f,#3a445c)'], + detail: { + summary: 'The 19th-century stone pier and the market behind it — ice, line-caught fish, and the same crews who have run these boats for a century. The best 45 minutes in the city if you like to watch work happen.', + links: [ { label: 'El Muelle', href: 'https://en.wikipedia.org/wiki/El_Muelle_(Santa_Marta)' } ] } }, + { id: 'S29', name: 'Parque de los Novios', kind: 'visit', slot: 'visit', state: 'planned', + at: [11.2395, -74.2085], dur: 45, durConf: 3, + suggested: { value: 45, conf: 2, src: 'santamarta.gov.co' }, + price: null, + img: ['💑', 'Parque de los Novios', 'linear-gradient(135deg,#6b8f4f,#445c2c)'], + detail: { + summary: 'The lakeside park where the city goes at dusk — the lagoon, the couples (it is in the name), and the walk back along the water to the hotel.', + links: [ { label: 'Santa Marta parks', href: 'https://santamarta.gov.co' } ] } } + ] + }, + { id: 'D7', date: '2026-09-27', label: 'Day 7 · Sun 27', + startMin: 7 * 60 + 30, wakingHours: 12, + base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] }, + legs: [], + stops: [ + { id: 'S30', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [11.2390, -74.2130], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S31', name: 'Bus → Tayrona (El Croado)', kind: 'transit', slot: 'transit', state: 'planned', + at: [11.1975, -74.1760], dur: 40, durConf: 3, + transit: { name: 'Bus Centro → El Croado', ref: 'TAY-01', + from: 'Santa Marta Centro', to: 'Parque Tayrona, El Croado', + depart: '08:30', arrive: '09:10', arriveBy: '08:30', + buffer: 'park gate opens 08:00' }, + suggested: null, + price: { amount: 4, cur: '$', conf: 3, src: 'parquetayrona.gov.co' }, + img: ['🚌', 'Bus to Tayrona', 'linear-gradient(135deg,#8f6b3c,#5c441f)'] }, + { id: 'S32', name: 'Playa Cangrejos', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://parquetayrona.gov.co', + at: [11.1765, -74.1690], dur: 60, durConf: 3, + suggested: { value: 60, conf: 2, src: 'parquetayrona.gov.co' }, + price: { amount: 15, cur: '$', conf: 3, src: 'parquetayrona.gov.co' }, + img: ['🌴', 'Playa Cangrejos', 'linear-gradient(135deg,#3cb5c9,#1f5c44)'], + detail: { + summary: 'The first beach on the park walk — jungle right up to the sand line, and the water so clear it looks edited. Two hours here is a long, correct rest after the walk in.', + links: [ { label: 'Parque Tayrona', href: 'https://parquetayrona.gov.co' } ] } }, + { id: 'S33', name: 'Cabo San Juan', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://parquetayrona.gov.co', + at: [11.1390, -74.1455], dur: 120, durConf: 3, + suggested: { value: 120, conf: 2, src: 'parquetayrona.gov.co' }, + price: null, + img: ['🏝️', 'Cabo San Juan', 'linear-gradient(135deg,#c95c8a,#8a1f5c)'], + detail: { + summary: 'The park’s far tip — the 2.5 km beach walk from Cangrejos is the trail: sand underfoot, jungle walls, the headland where the Caribbean hits the Sierra Nevada. September crowd levels are thin; the last boats out run 15:30, so turn around by 14:00.', + links: [ { label: 'Parque Tayrona', href: 'https://parquetayrona.gov.co' } ] } } + ] + }, + { id: 'D8', date: '2026-09-28', label: 'Day 8 · Mon 28', isDeparture: true, + startMin: 9 * 60, wakingHours: 6, + base: { name: 'Hotel Muelle de Bolívar', at: [11.2390, -74.2130] }, + legs: [], + stops: [ + { id: 'S34', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [11.2390, -74.2130], dur: 45, durConf: 3, + suggested: { value: 45, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S35', name: 'Vuelo SMR → BOG', kind: 'transit', slot: 'transit', state: 'planned', + at: [11.1165, -74.2330], dur: 45, durConf: 4, + transit: { name: 'AV 1352', ref: 'AV-88214', + from: 'Simón Bolívar (SMR)', to: 'Bogotá (BOG)', + depart: '15:10', arrive: '15:55', arriveBy: '14:10', + buffer: '1 h before departure' }, + suggested: null, + price: { amount: 95, cur: '$', conf: 4, src: 'booking' }, + img: ['✈️', 'AV 1352', 'linear-gradient(135deg,#274b8f,#16294d)'] } + ] + } + ], + candidates: { + hotel: [ + { id: 'H1', name: 'Hotel Casa San Antonio', at: [10.3952, -75.5522], price: 120, dur: null, + tags: ['Getsemaní', 'colonial casona', 'roof terrace'], pitch: 'Where you are now — a whitewashed casona in Getsemaní with a terrace over the murals.', url: 'https://www.tripadvisor.com' }, + { id: 'H2', name: 'Hotel Casa 1800', at: [10.4606, -75.5547], price: 260, dur: null, + tags: ['old town', 'design', 'pool'], pitch: 'In the old town instead — the 19th-century palace turned design hotel; pricier, but you skip the walk entirely.', url: 'https://www.casa1800.com' }, + { id: 'H3', name: 'Villa El Banderito', at: [10.4340, -75.5365], price: 140, dur: null, + tags: ['Bocagrande', 'beachfront', 'quiet'], pitch: 'On the Bocagrande sand — wake up at the water; a 15-min tuktuk from Getsemaní.', url: 'https://www.tripadvisor.com' } + ], + lunch: [ + { id: 'C1', name: 'La Casona de San Diego', at: [10.3940, -75.5545], price: 18, dur: 75, + tags: ['caribbean', 'getsemaní', 'cash only'], pitch: 'Sancocho and mango con arepa in a whitewashed casona — the neighbourhood’s own lunch.', + url: 'https://www.tripadvisor.com', img: ['🍲', 'La Casona', 'linear-gradient(135deg,#3c8f6b,#1f5c44)'] }, + { id: 'C2', name: 'La Loma de San Diego', at: [10.4590, -75.5530], price: 25, dur: 75, + tags: ['old town', 'arroz con coco', 'institution'], pitch: 'The old-town classic — big patio table, kitchen as theatre, go at 12:15.', + url: 'https://www.tripadvisor.com', img: ['🥘', 'La Loma', 'linear-gradient(135deg,#c93c3c,#8a1f1f)'] }, + { id: 'C3', name: 'Club El Boat', at: [10.4355, -75.5375], price: 20, dur: 90, + tags: ['bocagrande', 'seafood', 'sunset'], pitch: 'Langosta on the malecón — the sunset is the side order.', + url: 'https://www.tripadvisor.com', img: ['🦐', 'El Boat', 'linear-gradient(135deg,#3c6b8f,#1f445c)'] }, + { id: 'C4', name: 'La Terraza del Muelle', at: [11.2375, -74.2140], price: 20, dur: 75, + tags: ['santa marta', 'harbour', 'catch of the day'], pitch: 'Grilled fish over the working pier — the boats set the menu.', + url: 'https://www.tripadvisor.com', img: ['🐟', 'Terraza del Muelle', 'linear-gradient(135deg,#3c6b8f,#1f445c)'] } + ], + visit: [ + { id: 'V1', name: 'Playa Bocagrande', at: [10.4330, -75.5340], price: 0, dur: 90, + tags: ['beach', 'free', 'swim'], pitch: 'The city beach — a proper swim and a hammock, ten minutes from the old town by tuktuk.', + url: 'https://en.wikipedia.org/wiki/Bocagrande', img: ['🏖️', 'Bocagrande', 'linear-gradient(135deg,#3cb5c9,#1f7a8a)'] }, + { id: 'V2', name: 'Plaza Peñol', at: [10.4632, -75.5570], price: 0, dur: 30, + tags: ['free', 'old town', 'shades'], pitch: 'The old town’s leafy pause — the black umbrellas and the shade are the whole point.', + url: 'https://en.wikipedia.org/wiki/Cartagena', img: ['☂️', 'Plaza Peñol', 'linear-gradient(135deg,#4f7a4f,#2c4f2c)'] }, + { id: 'V3', name: 'Iglesia de la Popa', at: [10.4253, -75.5530], price: 10, dur: 30, + tags: ['view', 'hilltop', 'sunset'], pitch: 'The church itself in 15 min — the terrace does the rest.', + url: 'https://en.wikipedia.org/wiki/Cerro_de_La_Popa', img: ['⛪', 'Iglesia de la Popa', 'linear-gradient(135deg,#7a7a8f,#4f4f61)'] }, + { id: 'V4', name: 'Playa Cocal, Tayrona', at: [11.1650, -74.1620], price: 0, dur: 60, + tags: ['tayrona', 'beach', 'jungle'], pitch: 'The quieter of the two main Tayrona beaches — the jungle wall is closer to the sand here.', + url: 'https://parquetayrona.gov.co', img: ['🌴', 'Playa Cocal', 'linear-gradient(135deg,#4f9e63,#2c6b40)'] }, + { id: 'V5', name: 'Cerro de La Popa (Santa Marta)', at: [11.2460, -74.2020], price: 2, dur: 60, + tags: ['view', 'santa marta', 'sunset'], pitch: 'The hilltop over Santa Marta — city, gulf and the Sierra Nevada in one frame.', + url: 'https://en.wikipedia.org/wiki/Santa_Marta', img: ['🌄', 'Cerro La Popa', 'linear-gradient(135deg,#c95c3c,#8a3a1f)'] } + ] + }, + nudges: { + D1: [ + { id: 'N1', title: 'Getsemaní is best before 10:00', + body: 'The street-art lanes fill with tour groups by late morning. Your 09:30 start is the right call — keep it.', + fix: { label: 'Keep as is' } } + ], + D3: [ + { id: 'N2', title: 'Two forts in a day is a lot of stairs', + body: 'Castillo San Diego runs ~75 min for the terrace-and-bastion loop (web); 90 min if you take the rampart walk. As is, you’ll be tight but fine.', + fix: { label: 'Extend to ~90 min', stop: 'S14', dur: 90, + msg: 'San Diego extended to 90 min — the malecón stroll slides a little later; the day still fits.' } } + ], + D7: [ + { id: 'N3', title: 'Tayrona: water, sunscreen, and the 15:30 boats', + body: 'The Cangrejos → Cabo San Juan stretch is 2.5 km of beach, no shade and no vendors. The last boats out run 15:30 — your 14:00 turnaround is correct.', + fix: { label: 'Keep as is' } } + ], + D4: [], D5: [], D6: [], D8: [] + }, + multimodal: [ + { into: 'S22', dur: 30, sub: [ { mode: 'car', dur: 25 } ] }, // taxi Getsemaní → CTG airport + { into: 'S23', dur: 25, sub: [ { mode: 'car', dur: 20 } ] }, // taxi SMR → hotel + { into: 'S31', dur: 40, sub: [ { mode: 'bus', dur: 35 } ] }, // bus Centro → Tayrona + { into: 'S35', dur: 25, sub: [ { mode: 'car', dur: 20 } ] } // taxi hotel → SMR + ], + vibes: [ + { id: 'v1', label: 'forts & old town' }, + { id: 'v2', label: 'Caribbean beaches & malecón' }, + { id: 'v3', label: 'street art (Getsemaní)' }, + { id: 'v4', label: 'Tayrona day trip' }, + { id: 'v5', label: 'Caribbean food' } + ], + suggestions: [ + { stop: 'S2', pitch: 'A hundred walls of murals between narrow lanes — start at Plaza de los Coches and wander to the market.', + enrich: 'free · best before 10:00 · ~60 min' }, + { stop: 'S13', pitch: 'Five levels of ramparts, tunnels and cannon lines — go at opening (08:30) for the cold tunnels and no tour buses.', + enrich: 'open 08:30 · ~$15 · 90–120 min' }, + { stop: 'S11', pitch: 'The best single view in Cartagena — be on the terrace 45 min before sunset.', + enrich: 'open 09:00–18:00 · ~$10' }, + { stop: 'S33', pitch: 'Sand underfoot, jungle walls, the headland where the Caribbean hits the Sierra Nevada. Last boats out 15:30.', + enrich: 'park 08:00–16:00 · entry ~$15 · 2.5 km beach walk' } + ] +}; +// ---------------- trip registry (multi-trip) ---------------- +// Each trip is a full dataset. The app keeps per-trip state in +// localStorage (see app.js), so several trips can be worked on in parallel. +window.TRIPS = { + colombia: Object.assign({}, COLOMBIA), + boston: Object.assign({ id: 'boston' }, BOSTON), + florence: Object.assign({ id: 'florence' }, FLORENCE), +}; +// ?trip= (legacy: ?city=) picks the starting trip; default: the one you're working on +const _city = ((new URLSearchParams(location.search).get('trip')) + || (new URLSearchParams(location.search).get('city')) + || 'colombia').toLowerCase(); +window.MOCK = window.TRIPS[_city] || window.TRIPS.colombia; +window.MOCK_CITY = window.MOCK.id; diff --git a/mock/index.html b/mock/index.html index 496069b..89ffdce 100644 --- a/mock/index.html +++ b/mock/index.html @@ -4,6 +4,7 @@ Trip + @@ -25,9 +26,10 @@
- or explore a demo trip - + or pick a trip + +
@@ -47,7 +49,10 @@