From 30f3dd9cbf6c89ecde1cc7c504ea8df7551609fd Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Wed, 9 Sep 2026 00:47:50 -0400 Subject: [PATCH] mock: wire to the real local OSRM router + L0 trip view, version history, offline cues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server.js: proxy /route, /table, /router-status to the local OSRM on :5000 - app.js: routeClient (polyline-5 decode, ordered route fetch); legs start as straight-line estimates (conf 1) and are upgraded in the background to real road geometry + duration (conf 3); out-of-coverage trips (Florence) fall back gracefully with a 'estimate — outside router coverage' source - L3 leg editor: dragging a waypoint now genuinely re-routes through the router (was silently no-op — #app covered the map with pointer-events:auto, so no map drag ever landed; #app is now pointer-events:none, children opt in) - data.js: second demo city (Boston 14–16 Sep, inside the router's NE-US extract) via ?city=boston; nudges + multimodal legs are now dataset-driven - L0: Day/Trip scope toggle — per-day summary cards (pacing bar, stops, end time, price, base) with click-to-jump; map fits the whole trip - version history: commit() snapshots days+stays on every mutation; topbar vN pill with undo/redo + dropdown timeline showing a diff label per version; Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y; restore() re-enriches legs (snapshots may predate route enrichment) - offline: staged 'prepare for offline' card in the trip rail (itinerary → POI details → vector tiles) that flips the topbar badge to '📦 offline ready' - chat/scripts/nudges: Florence-specific strings generalized (stay name/dates, vibes, pacing summary) so both demo cities read naturally --- mock/app.js | 423 +++++++++++++++++++++++++++++++++++++++--------- mock/data.js | 263 +++++++++++++++++++++++++++++- mock/index.html | 22 ++- mock/server.js | 42 ++++- mock/styles.css | 65 +++++++- 5 files changed, 735 insertions(+), 80 deletions(-) diff --git a/mock/app.js b/mock/app.js index 2b17c55..bcf63c2 100644 --- a/mock/app.js +++ b/mock/app.js @@ -21,6 +21,80 @@ const deep = o => JSON.parse(JSON.stringify(o)); const WALK_KMH = 4.6; const walkMin = (a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (WALK_KMH / 60))); +// ---------------- route client (local OSRM via the /route proxy) ---------- +// Legs start life as straight-line estimates (conf 1) and are upgraded in the +// 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; +async function checkRouter() { + try { const r = await fetch('/router-status'); routerOn = !!(await r.json()).router; } + catch { routerOn = false; } +} +// Google encoded-polyline decoder (OSRM `overview=full` geometry) +// OSRM encodes with precision 5 (unlike Google’s default 6) +function decodePolyline(str, precision = 5) { + const out = []; + let p = 0, lat = 0, lng = 0; + const next = () => { // one zigzag varint component + let result = 0, shift = 0, b; + do { b = str.charCodeAt(p++) - 63; result += (b & 31) << shift; shift += 5; } while (b >= 32); + return (result & 1) ? ~(result >> 1) : (result >> 1); + }; + while (p < str.length) { + lat += next(); + lng += next(); + out.push([lng / 10 ** precision, lat / 10 ** precision]); // [lng, lat] + } + return out; +} +// route an ordered list of [lat,lng] points; null when unroutable (out of coverage) +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 j = await r.json(); + const route = j?.routes?.[0]; + if (!route || !route.distance) return null; + 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] + }; + } catch { return null; } +} +let legToken = 0; +// background enrichment: upgrade each straight-line leg to a real route +async function enrichLegs(d = day) { + if (!routerOn) return; + const token = ++legToken; + 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; + 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]; + if (res) { + const changed = leg.dur !== res.durMin || !leg.geometry; + leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; + leg.src = 'local router'; + if (changed) renderAll(); + } else { + leg.conf = 1; leg.src = 'estimate — outside router coverage'; + renderAll(); + } + } + // return-to-hotel leg when a single stay anchors the day + if (!d.isDeparture && d.stops.length && staysForDay(d).length === 1) { + const last = d.stops[d.stops.length - 1]; + if (last.kind !== 'region' && last.kind !== 'transit') { + const res = await routePts([last.at, dayBase(d).at], 'foot'); + if (token !== legToken) return; + if (res) { d.retRouted = res; renderAll(); } + } + } +} + // ---------------- state ---------------- const days = {}; M.days.forEach(d => days[d.id] = deep(d)); @@ -78,7 +152,8 @@ function renderHotelMks() { let hotelMks = [], trainMks = []; function initMap() { - map = L.map('map', { zoomControl: false }).setView([43.769, 11.252], 14); + const c = M.trip.places?.[0]?.center || [43.769, 11.252]; + map = L.map('map', { zoomControl: false }).setView(c, 14); L.control.zoom({ position: 'bottomright' }).addTo(map); L.tileLayer('/tiles/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors · local tile proxy :8077', maxZoom: 19 @@ -113,6 +188,8 @@ function returnDur(d = day) { if (d.isDeparture) return 0; // departure day: the train is the end of the day const last = d.stops[d.stops.length - 1]; if (!last) return 0; + // a single stay anchored the day and the router has a real walk → use it + if (d.retRouted && staysForDay(d).length === 1 && last.kind !== 'transit') return d.retRouted.durMin; const list = staysForDay(d); // worst case across every candidate stay for these nights (equals the real walk when one) return Math.max(1, Math.ceil(Math.max(...list.map(o => haversine(last.at, o.at))) / 1000 / (WALK_KMH / 60))); @@ -124,19 +201,25 @@ function layout(d = day) { } function rebuildLegs(d = day) { d.legs = []; + delete d.retRouted; for (let i = 0; i < d.stops.length - 1; i++) { const a = d.stops[i], b = d.stops[i + 1]; // a vague (region) stop at either end → worst-case leg until refined if (a.kind === 'region' || b.kind === 'region') { d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true }); - } else if (b.id === 'S5') { - d.legs.push({ mode: 'multimodal', dur: 18, conf: 3, sub: [{ mode: 'foot', dur: 6 }, { mode: 'tram', dur: 12 }] }); } else { - let h = 0; const key = a.name + '→' + b.name; - for (const c of key) h = (h * 31 + c.charCodeAt(0)) % 997; - d.legs.push({ mode: 'foot', dur: 8 + h % 15, conf: 3 }); + 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 }); + } else { + let h = 0; const key = a.name + '→' + b.name; + for (const c of key) h = (h * 31 + c.charCodeAt(0)) % 997; + // straight-line estimate until the router upgrades it (or proves out of coverage) + d.legs.push({ mode: 'foot', dur: 8 + h % 15, conf: 1 }); + } } } + enrichLegs(d); } // ---------------- vague / region placeholder stops ---------------- @@ -182,14 +265,18 @@ function renderMap() { return; } const pts = obj.stops.map(s => s.at); - for (let i = 0; i < pts.length - 1; i++) - legEls[i] = L.polyline([pts[i], pts[i + 1]], { + for (let i = 0; i < pts.length - 1; i++) { + // 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 }).addTo(map); + } if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row) staysForDay(obj).forEach(o => { - retLine = L.polyline([pts[pts.length - 1], o.at], { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }).addTo(map); + const g = (obj.retRouted && staysForDay(obj).length === 1) ? obj.retRouted.geometry : [pts[pts.length - 1], o.at]; + retLine = L.polyline(g, { color: '#7a8291', weight: 4, opacity: .8, dashArray: '1 7' }).addTo(map); otherLayers.push(retLine); retLines.push(retLine); }); @@ -207,12 +294,15 @@ function renderMap() { }); }); const cur = days[curDay]; - if (cur.stops.length) { + const pts = scope === 'trip' + ? M.days.flatMap(md => days[md.id].stops.map(s => s.at)) + : cur.stops.map(s => s.at); + if (pts.length) { const discOpen = !$('#discover').classList.contains('hidden'); 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), ...cur.stops.map(s => s.at)]), pad); + map.fitBounds(L.latLngBounds([dayBase().at, ...stays.map(o => o.at), ...pts]), pad); } } function highlightStop(id, on) { @@ -425,6 +515,7 @@ function promoteCand(c, slot, state = 'planned', flash = true) { if (at >= 0) day.stops.splice(at, 0, stop); else day.stops.push(stop); } rebuildLegs(); + commit(`${stop.name} → ${stateLabel(state)}${incumbent ? ' (swapped for ' + incumbent.name + ')' : ''}`); renderAll(flash ? [stop.id] : []); return { stop, incumbent: stop.slot === slot && stop.state === 'planned' ? day.stops.find(x => x.slot === slot && x.state === 'alt' && x !== stop) : null }; } @@ -437,7 +528,7 @@ function chooseCand(c, slot) { stays.push({ id: 'ST' + (++staySeq), name: c.name, at: c.at, place: cur.place, checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'booked', price: c.price }); renderHotelMks(); renderBaseChip(); - closeDiscover(); renderAll(); + closeDiscover(); commit(`hotel → ${c.name}`); renderAll(); ai(`Now staying at ${c.name} for ${stayRange(cur)} — those nights re-anchor on it, and the “back to hotel” legs dropped from worst-case to real times.`, 700); return; } @@ -460,7 +551,7 @@ function holdIdea(c, slot) { stays.push({ id: 'ST' + (++staySeq), name: c.name, at: c.at, place: cur.place, checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'alt', price: c.price }); renderHotelMks(); renderBaseChip(); - closeDiscover(); renderAll(); + closeDiscover(); commit(`hotel option: ${c.name}`); renderAll(); ai(`Keeping ${c.name} open for ${stayRange(cur)} — “back to hotel” now runs worst-case across ${staysForDay().length} candidate hotels until you commit (“make this the hotel” when you decide).`, 800); return; } @@ -662,7 +753,7 @@ function renderStopCard(body, s, i, flashIds) { else ai(`Dropped ${cand.name} into ${dayLabel()}.`, 500); } else { day.stops.push(makeStop(cand, slot, 'maybe')); - rebuildLegs(); renderAll([cand.id]); + rebuildLegs(); commit(`idea: ${cand.name}`); renderAll([cand.id]); ai(`Dropped ${cand.name} into ${dayLabel()} as an idea (different slot) — promote it whenever you like.`, 500); } if (disc) closeDiscover(); @@ -673,6 +764,7 @@ function renderStopCard(body, s, i, flashIds) { const [moved] = day.stops.splice(from, 1); day.stops.splice(i, 0, moved); rebuildLegs(); + commit(`moved ${moved.name}`); renderAll([moved.id]); }); c.addEventListener('mouseenter', () => highlightStop(s.id, true)); @@ -739,7 +831,7 @@ function setState(s, state) { if (cur) { cur.state = 'alt'; ai(`Swapped: ${s.name} is now your ${s.slot}; ${cur.name} becomes the backup.`, 600); } } s.state = state; - rebuildLegs(); renderAll([s.id]); + rebuildLegs(); commit(`${s.name} → ${stateLabel(state)}`); renderAll([s.id]); if (!(state === 'planned' && day.stops.find(x => x.slot === s.slot && x !== s && x.state === 'alt'))) ai(`${s.name} is now “${stateLabel(state)}”.`, 400); } @@ -748,7 +840,7 @@ function moveStopToDay(s, targetId) { const t = days[targetId]; t.stops.push(s); rebuildLegs(); if (t !== day) rebuildLegs(t); - renderAll(); + commit(`${s.name} → ${t.label}`); renderAll(); ai(`Moved ${s.name} to ${t.label} — legs and times re-timed there.`, 500); } const dayLabel = () => M.days.find(d => d.id === day.id).label; @@ -771,26 +863,9 @@ function renderBudget() { } // ---------------- nudges (issues, but gentle) ---------------- -const nudgesByDay = { - D1: [ - { id: 'N1', fixed: false, - title: 'Lunch may be a little tight', - body: '45 min at Trattoria Mario — a meal there usually runs ~90 min (web), and the local queue adds to that.', - fix: { label: 'Extend to ~90 min', - apply() { const s = stopById('S4'); if (s) s.dur = 90; }, - msg: 'Lunch extended to 90 min — everything after it slides later; the day still fits.' } }, - { id: 'N2', fixed: false, - title: 'Pitti: 1 h (you) vs ~2.5 h usually', - body: 'Guides suggest 2–3 h for the full complex. We kept your 1 h — no need to change anything.', - fix: { label: 'Keep as is', apply() {} } } - ], - D2: [], D3: [ - { id: 'N3', fixed: false, - title: 'Dinner is still an idea', - body: 'Il Latte is an option, not a plan — worth deciding by this evening so there’s a fallback if it’s full.', - fix: { label: 'Keep as is', apply() {} } } - ] -}; +// Data-driven (M.nudges); a runtime copy carries the per-day `fixed` flags. +const nudgesByDay = deep(M.nudges || {}); +Object.values(nudgesByDay).forEach(list => list.forEach(n => n.fixed = false)); function activeNudges() { return (nudgesByDay[day.id] || []).filter(n => !n.fixed); } function renderNudges() { const p = $('#nudges'); if (!p) return; p.innerHTML = ''; @@ -802,7 +877,9 @@ function renderNudges() { d.append(el('div', 'nb', n.body)); const b = el('button', 'btn ghost small', n.fix.label); b.onclick = () => { - n.fixed = true; n.fix.apply(); + n.fixed = true; + if (n.fix.stop && n.fix.dur) { const st = stopById(n.fix.stop); if (st) st.dur = n.fix.dur; } + commit(`nudge: ${n.fix.label}`); renderAll(); if (n.fix.msg) ai(n.fix.msg, 300); }; @@ -944,13 +1021,23 @@ function openL3(legIdx) { const mk = p => L.marker(p, { draggable: true, icon: L.divIcon({ className: 'wp', html: '
', iconSize: [16, 16], iconAnchor: [8, 8] }) }).addTo(layer); const m1 = mk(a), m2 = mk(mid), m3 = mk(b); const box = $('#l3-editor .l3-time'); + let rt = 0; // debounce token for the router round-trip const upd = () => { const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng(); line.setLatLngs([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]]); const dist = haversine([p1.lat, p1.lng], [p2.lat, p2.lng]) + haversine([p2.lat, p2.lng], [p3.lat, p3.lng]); - leg.dur = Math.max(4, Math.round(dist / 4800 * 60)); // 4.8 km/h walking - box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, 3)); + leg.dur = Math.max(4, Math.round(dist / 4800 * 60)); // instant straight-line estimate + leg.conf = 1; + box.innerHTML = 'leg time: est. '; box.append(tchip(leg.dur, 1)); renderBudget(); + const t = ++rt; + routePts([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]], 'foot').then(res => { + if (t !== rt || !l3) return; + if (res) { leg.dur = res.durMin; leg.conf = 3; leg.geometry = res.geometry; line.setLatLngs(res.geometry); } + box.innerHTML = 'leg time: ' + (res ? '' : 'est. '); + box.append(tchip(leg.dur, leg.conf)); + renderBudget(); + }); }; [m1, m2, m3].forEach(m => m.on('drag', upd)); $('#l3-title').textContent = `${day.stops[legIdx].name} → ${day.stops[legIdx + 1].name}`; @@ -961,6 +1048,7 @@ function openL3(legIdx) { const obs = setInterval(() => { if (!$('#l3-editor').classList.contains('hidden')) return; clearInterval(obs); + commit('edited a leg route'); renderAll(); }, 250); } @@ -970,6 +1058,79 @@ function closeL3() { } $('#l3-close').onclick = closeL3; +// ---------------- version history (every edit = a version) ---------------- +// Both editors (user + chat) funnel through commit(): the snapshot captures +// days + stays, so undo/redo/revert is a single code path. +let version = 0; +let history = []; // [{v, label, at, days, stays}] +let hIdx = -1; // pointer into history +const commit = (label) => { + history = history.slice(0, hIdx + 1); // drop any redo branch + history.push({ v: ++version, label, at: Date.now(), days: deep(days), stays: deep(stays) }); + hIdx = history.length - 1; + if (history.length > 60) { history.shift(); hIdx--; } + renderVersionPill(); +}; +function restore(idx, silent) { + const s = history[idx]; if (!s) return; + hIdx = idx; + Object.keys(days).forEach(k => { Object.assign(days[k], deep(s.days[k])); }); + stays = deep(s.stays); + staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1); + closeDiscover(); closeL3(); + rebuildLegs(day); // snapshots may predate route enrichment — re-upgrade legs + renderHotelMks(); renderBaseChip(); + renderAll(); + renderVersionPill(); + if (!silent) ai(`↩ Reverted to v${s.v} — ${s.label}. Later edits are gone from the branch; redo can’t bring them back.`, 400); +} +function undo() { if (hIdx > 0) restore(hIdx - 1); } +function redo() { if (hIdx < history.length - 1) restore(hIdx + 1); } +function renderVersionPill() { + const elV = $('#verlabel'); if (!elV) return; + elV.textContent = 'v' + version; + $('#ver-undo').classList.toggle('off', hIdx <= 0); + $('#ver-redo').classList.toggle('off', hIdx >= history.length - 1); +} +// what changed between that version and the current plan (stop-level, per day) +function diffVsCurrent(snap) { + const parts = []; + M.days.forEach(md => { + const A = (snap.days[md.id] || {}).stops || []; + const B = (days[md.id] || {}).stops || []; + const aSet = new Set(A.map(x => x.id)), bSet = new Set(B.map(x => x.id)); + const added = B.filter(x => !aSet.has(x.id)).map(x => x.name); + const removed = A.filter(x => !bSet.has(x.id)).map(x => x.name); + if (added.length || removed.length) + parts.push(`${md.label.split(' · ')[0]}${added.length ? ' +' + added.join(', +') : ''}${removed.length ? ' −' + removed.join(', −') : ''}`); + }); + return parts.slice(0, 3); +} +function toggleVerList() { + const p = $('#verlist'); + if (!p.classList.contains('hidden')) { p.classList.add('hidden'); return; } + p.innerHTML = ''; + const rows = history.map((s, i) => ({ s, i })).slice(-14).reverse(); + rows.forEach(({ s, i }) => { + const r = el('div', 'ver-row' + (i === hIdx ? ' cur' : '')); + const when = new Date(s.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const d = diffVsCurrent(s); + r.append(el('div', 'vr-main', `v${s.v} · ${when} · ${s.label}` + (i === hIdx ? ' current' : ''))); + if (d.length && i !== hIdx) r.append(el('div', 'vr-diff', d.join(' · '))); + r.onclick = () => { p.classList.add('hidden'); if (i !== hIdx) restore(i); }; + p.append(r); + }); + p.classList.remove('hidden'); +} +$('#ver-undo').onclick = undo; +$('#ver-redo').onclick = redo; +$('#verlabel').onclick = toggleVerList; +document.addEventListener('click', e => { if (!e.target.closest('#verpill, #verlist')) $('#verlist')?.classList.add('hidden'); }); +document.addEventListener('keydown', e => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z') { e.preventDefault(); e.shiftKey ? redo() : undo(); } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'y') { e.preventDefault(); redo(); } +}); + // ---------------- day tabs ---------------- function buildDayTabs() { const tabs = $('#daytabs'); tabs.innerHTML = ''; @@ -1001,7 +1162,107 @@ function setDay(id) { if (disc) closeDiscover(); // the drawer’s anchor is day-specific renderAll(); } -function renderAll(flashIds = []) { renderRail(flashIds); renderMap(); } +let scope = 'day'; // 'day' (default working view) | 'trip' (L0 macro overview) +function renderAll(flashIds = []) { + if (scope === 'trip') renderTripRail(); + else renderRail(flashIds); + renderMap(); +} +function setScope(sc) { + scope = sc; + document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === sc)); + $('#daystrip').classList.toggle('hidden', sc === 'trip'); + $('#rail-foot').classList.toggle('hidden', sc === 'trip'); + renderAll(); +} +$('#scope-day').onclick = () => setScope('day'); +$('#scope-trip').onclick = () => setScope('trip'); + +// ---------------- L0 trip overview (macro scale) ---------------- +function dayStats(d) { + const planned = d.stops.filter(s => s.state === 'planned'); + const dur = planned.reduce((a, s) => a + s.dur, 0) + d.legs.reduce((a, g) => a + g.dur, 0) + + (planned.length && !d.isDeparture ? returnDur(d) : 0); + return { count: planned.length, dur, end: layout(d), + price: planned.reduce((a, s) => a + (s.price?.amount || 0), 0), + flex: d.stops.filter(s => s.state !== 'planned').length, cap: d.wakingHours * 60 }; +} +function renderTripRail() { + const body = $('#rail-body'); body.innerHTML = ''; + const total = M.days.reduce((a, md) => a + dayStats(days[md.id]).price, 0); + const head = el('div', 'trip-head'); + head.append(el('div', 'trip-title', M.trip.title)); + head.append(el('div', 'trip-sub', `${M.days.length} days · ~${total} spent in-plan · bookings are hard anchors`)); + body.append(head); + M.days.forEach(md => { + const d = days[md.id]; + const st = dayStats(d); + const pct = Math.min(100, st.dur / st.cap * 100); + const card = el('div', 'trip-day' + (md.id === curDay ? ' cur' : '')); + const top = el('div', 'td-top'); + top.append(el('span', 'td-label', md.label + (d.isDeparture ? ' · dep' : ''))); + top.append(el('span', 'td-end', `ends ~${fmt(st.end)}`)); + card.append(top); + const bar = el('div', 'td-bar'); + const fill = el('div', 'td-fill'); + fill.style.width = pct + '%'; + fill.classList.toggle('warn', pct > 92); + bar.append(fill); + card.append(bar); + const meta = el('div', 'td-meta'); + meta.append(el('span', null, `${st.count} stops${st.flex ? ' · ' + st.flex + ' ideas' : ''}`)); + meta.append(el('span', 'pchip', `~${(st.dur / 60).toFixed(1)} h / ${md.wakingHours} h`)); + const cur = (d.stops.find(x => x.price)?.price)?.cur || ''; + if (st.price) meta.append(el('span', 'pchip', `~${st.price}${cur} spent`)); + card.append(meta); + const names = d.stops.filter(s => s.state === 'planned').map(s => s.name); + card.append(el('div', 'td-names', names.slice(0, 4).join(' → ') + (names.length > 4 ? ' …' : ''))); + card.onclick = () => { setDay(md.id); setScope('day'); }; + body.append(card); + }); + body.append(offlineCard()); +} + +// ---------------- on-trip offline bundle (simulated) ---------------- +let offlineState = 'idle'; // idle | working | ready +function offlineCard() { + const c = el('div', 'offline-card'); + if (offlineState === 'ready') { + c.append(el('div', 'oc-title', '📦 Offline bundle ready')); + c.append(el('div', 'oc-sub', `Itinerary v${version}, stop details (hours, tags) and the trip-area map tiles are on-device — today-view and navigation keep working without signal; re-planning needs connectivity.`)); + } else if (offlineState === 'working') { + c.append(el('div', 'oc-title', '📦 Preparing offline bundle…')); + c.append(el('div', 'oc-sub oc-progress', 'itinerary ✓ → POI details…')); + } else { + c.append(el('div', 'oc-title', '📶 On-trip: prepare an offline bundle')); + c.append(el('div', 'oc-sub', 'Downloads the plan, stop details (opening hours run fully offline) and vector map tiles for the trip area — so the plan keeps working in thin-coverage spots.')); + const b = el('button', 'btn ghost small', 'Prepare for trip'); + b.onclick = () => prepareOffline(); + c.append(b); + } + return c; +} +function prepareOffline() { + offlineState = 'working'; + renderTripRail(); + const steps = [ + ['itinerary ✓ → POI details…', 500], + ['POI details (hours, tags) ✓ → vector tiles…', 1200], + [`vector tiles · trip bbox (${120 + M.days.length * 40} tiles) ✓`, 2100], + ]; + steps.forEach(([txt, t]) => setTimeout(() => { + if (offlineState !== 'working') return; + const p = document.querySelector('.oc-progress'); + if (p) p.textContent = txt; + }, t)); + setTimeout(() => { + offlineState = 'ready'; + const badge = $('#offline-badge'); + if (badge) { badge.textContent = '📦 offline ready'; badge.classList.add('ok'); badge.title = 'Trip bundle cached on-device'; } + if (scope === 'trip') renderTripRail(); + ai('Offline bundle prepared — itinerary, stop details and the trip-area map tiles are cached. On-trip it degrades gracefully: plan + navigation keep working, re-planning waits for signal.', 900); + }, 2500); +} // ---------------- chat ---------------- function msg(role, html) { @@ -1046,15 +1307,18 @@ function handleUser(v) { } if (s.includes('later') || s.includes('morning')) { day.startMin += 30; + commit('start day 30 min later'); renderAll(day.stops.map(x => x.id)); ai(`Pushed ${dayLabel()} back 30 min — everything re-timed. Day now ends at ${fmt(layout())}.`); } else if (s.includes('lunch') || s.includes('longer')) { - const n = activeNudges()[0]; - const lunch = day.stops.find(x => x.slot === 'lunch' && x.state === 'planned'); - if (n && !n.fixed && lunch) { - n.fixed = true; lunch.dur = 90; - renderAll([lunch.id]); - ai(`Done — lunch now runs 90 min. What follows it slides to ${fmt(day.stops[day.stops.indexOf(lunch) + 1]?.start ?? 0)}.`); + const n = activeNudges().find(x => x.fix?.stop && x.fix?.dur); + const st = n ? stopById(n.fix.stop) : null; + if (n && !n.fixed && st) { + n.fixed = true; st.dur = n.fix.dur; + commit(`nudge: ${n.fix.label}`); + rebuildLegs(day); + renderAll([st.id]); + ai(n.fix.msg || `Done — ${st.name} now runs ${n.fix.dur} min; what follows it slides later.`); } else ai(`There’s nothing to loosen on ${dayLabel()} right now — the day looks good.`); } else { ai('I can shift times, swap stops, or move things between days — e.g. “start the day later”. For finding, the drawer chips on the map (lunch / afternoon / hotel) are always there; for swapping, expand a stop card’s alternatives or drag a candidate straight onto the plan.', 900); @@ -1064,16 +1328,23 @@ $('#chat-send').onclick = chatSend; $('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend()); // ---------------- landing → parse → plan ---------------- -const SAMPLE = [ - ['type', 'Hotel reservation', 'lodging — anchors mornings and evenings', 'hi'], - ['name', 'Hotel Palagio, Florence', 'geocoded → 20 m from SMN station', 'hi'], - ['dates', '12 – 15 Sep 2026', '3 nights', 'hi'], - ['window', 'check-in 15:00 · check-out 11:00', 'parsed from e-mail body', 'mid'], - ['ref', 'Confirmation H-77821', 'read from footer image — worth a double-check', 'mid'] -]; +// parsed-booking preview, derived from the active dataset (city-aware) +function sampleRows() { + const hb = M.trip.bookings.find(b => b.type === 'hotel'); + const tb = M.trip.bookings.find(b => b.type === 'train'); + const rows = [ + ['type', 'Hotel reservation', 'lodging — anchors mornings and evenings', 'hi'], + ['name', `${hb.name}, ${M.trip.places[0].name}`, 'geocoded', 'hi'], + ['dates', hb.dates + ' ' + (M.trip.places[0].dates[0] || '').slice(0, 4), 'booked stay', 'hi'], + ['window', 'check-in 15:00 · check-out 11:00', 'parsed from e-mail body', 'mid'], + ['ref', `Confirmation ${hb.ref}`, 'read from footer image — worth a double-check', 'mid'], + ]; + if (tb) rows.push(['train', tb.name, `${tb.from} → ${tb.to} · dep ${tb.depart}`, 'hi']); + return rows; +} function showParse() { const t = $('#parse-table'); t.innerHTML = ''; - SAMPLE.forEach(([k, v, note, c]) => { + sampleRows().forEach(([k, v, note, c]) => { const tr = document.createElement('tr'); tr.innerHTML = `${k}${v}${c === 'hi' ? 'confirmed' : 'verify'}
${note}
`; t.append(tr); @@ -1092,10 +1363,12 @@ function startApp() { $('#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(); } @@ -1171,6 +1444,7 @@ function addStopToDay(id, requested) { copy.state = state; day.stops.push(copy); rebuildLegs(); + commit(`${copy.name} → ${stateLabel(state)}`); renderAll([id]); return state; } @@ -1178,22 +1452,22 @@ function addStopToDay(id, requested) { function script1() { setTimeout(() => { scriptBusy = true; - ai('Anchored your stay — Hotel Palagio, 12–15 Sep; the 15th’s train (09:12) is pinned as well. The drawer on the map (lunch / afternoon / hotel) is open whenever you want to browse — what kind of trip are you after?', 900); + { const b0 = dayBase(); ai(`Anchored your stay — ${b0.name}, ${stayRange(b0)}; bookings are pinned as hard anchors. The drawer on the map (lunch / afternoon / hotel) is open whenever you want to browse — what kind of trip are you after?`, 900); } setTimeout(() => { const m = msg('ai', 'Pick a vibe — or tell me in your own words:'); const chips = el('div', 'chips'); M.vibes.forEach(v => { const c = el('button', 'chip', v.label); - c.onclick = () => { if (scriptBusy) return; [...chips.children].forEach(x => x.classList.remove('sel')); c.classList.add('sel'); script2(); }; + c.onclick = () => { if (scriptBusy) return; [...chips.children].forEach(x => x.classList.remove('sel')); c.classList.add('sel'); script2(v.label); }; chips.append(c); }); m.append(chips); }, 1800); }, 300); } -function script2() { +function script2(vibe) { scriptBusy = true; - ai('Noted — relaxed, food-first. A few ideas for your first day:', 900); + ai(`Noted — ${vibe || M.vibes[0].label}. A few ideas for your first day:`, 900); setTimeout(() => { const m = msg('ai', ''); const cards = el('div', 'cards'); @@ -1270,15 +1544,10 @@ function script2() { } function script3() { setTimeout(() => { - const rank = { S1: 0, S2: 1, S4: 2, S7: 2.5, S5: 3, S6: 3.5 }; - day.stops.sort((a, b) => (rank[a.id] ?? 99) - (rank[b.id] ?? 99)); - if (!stopById('S6')) { - const gel = deep(allStops('S6')); - day.stops.push(gel); - } - rebuildLegs(); + rebuildLegs(day); renderAll(day.stops.map(s => s.id)); - ai('Day sequenced, gelato after the gardens. ' + ((layout() - day.startMin) / 60).toFixed(1) + ' h of 11 h waking — comfortably paced. Two gentle nudges below: lunch looks tight, and Pitti is at 1 h vs ~2.5 h usually — kept your 1 h.', 1100); + const st = dayStats(day); + ai(`Day sequenced — ${(st.dur / 60).toFixed(1)} h of ${day.wakingHours} h waking, ending ~${fmt(st.end)}. Anything that looks tight shows up as a nudge in the rail.`, 1100); }, 1400); } @@ -1288,13 +1557,16 @@ const dz = $('#dropzone'); ['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('over'); })); dz.addEventListener('drop', e => { if (e.dataTransfer?.files?.length) showParse(); }); $('#dz-sample').onclick = showParse; -$('#dest-go').onclick = () => { - const v = $('#dest').value.trim(); - if (!v) { showParse(); return; } - SAMPLE[1] = ['name', v, 'geocoded', 'hi']; - showParse(); -}; +$('#dest-go').onclick = () => { showParse(); }; $('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').click()); +// ?city= preselects the demo city on the landing screen; the demo chips switch dataset +(function () { + const c = new URLSearchParams(location.search).get('city'); + if (c && M.trip.places[0]) $('#dest').value = M.trip.places[0].name; + document.querySelectorAll('#land-demo .chip').forEach(b => b.onclick = () => { + location.href = location.pathname + '?city=' + b.dataset.city; + }); +})(); // drop a candidate onto the plan panel itself → hold it as an idea (function () { @@ -1308,10 +1580,15 @@ $('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').c if (!found) return; const { c, slot } = found; dragCand = null; day.stops.push(makeStop(c, slot, 'maybe')); - rebuildLegs(); renderAll([c.id]); + rebuildLegs(); commit(`idea: ${c.name}`); renderAll([c.id]); if (disc) closeDiscover(); ai(`Dropped ${c.name} into ${dayLabel()} as an idea — promote it whenever you like.`, 500); }); })(); 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'; +}); diff --git a/mock/data.js b/mock/data.js index 8e3331f..c44edfd 100644 --- a/mock/data.js +++ b/mock/data.js @@ -2,7 +2,11 @@ // Each stop: day, slot (mutual-exclusion group), state: planned | maybe | alt. // Only one `planned` stop per slot per day; `alt`/`maybe` don't count against // the day's time budget. -window.MOCK = { +// +// Two demo cities: Florence (rich itinerary demo) and Boston (inside the +// local OSRM extract, so legs are routed on the real road network — see +// server.js /route proxy and app.js routeClient). ?city=florence|boston +const FLORENCE = { trip: { id: 'trip-2026-09', version: 1, @@ -244,6 +248,26 @@ window.MOCK = { ] }, + nudges: { + D1: [ + { id: 'N1', title: 'Lunch may be a little tight', + body: '45 min at Trattoria Mario — a meal there usually runs ~90 min (web), and the local queue adds to that.', + fix: { label: 'Extend to ~90 min', stop: 'S4', dur: 90, + msg: 'Lunch extended to 90 min — everything after it slides later; the day still fits.' } }, + { id: 'N2', title: 'Pitti: 1 h (you) vs ~2.5 h usually', + body: 'Guides suggest 2–3 h for the full complex. We kept your 1 h — no need to change anything.', + fix: { label: 'Keep as is' } } + ], + D2: [], + D3: [ + { id: 'N3', title: 'Dinner is still an idea', + body: 'Il Latte is an option, not a plan — worth deciding by this evening so there’s a fallback if it’s full.', + fix: { label: 'Keep as is' } } + ] + }, + multimodal: [ + { into: 'S5', dur: 18, sub: [{ mode: 'foot', dur: 6 }, { mode: 'tram', dur: 12 }] } + ], vibes: [ { id: 'v1', label: 'art & food · relaxed pace' }, { id: 'v2', label: 'gardens & walking' }, @@ -262,3 +286,240 @@ window.MOCK = { enrich: 'lunch from 12:30 · book ahead in season' } ] }; + +// --------------------------------------------------------------------------- +// Boston — same shapes, but INSIDE the local OSRM extract (NE-US), so the +// routeClient upgrades legs to real road-network times + geometry. +// --------------------------------------------------------------------------- +const BOSTON = { + trip: { + id: 'trip-2026-09-b', + version: 1, + title: 'Boston 14–16 Sep', + stays: [ + { id: 'ST1', name: 'Hotel Commonwealth', at: [42.3495, -71.0960], place: 'Boston', + checkIn: '2026-09-14', checkOut: '2026-09-16', state: 'booked', price: 165 }, + ], + places: [ + { id: 'PL1', name: 'Boston', bbox: [42.33, -71.12, 42.375, -71.04], + center: [42.357, -71.063], vibe: 'history + harbor, food-first', + dates: ['2026-09-14', '2026-09-16'] } + ], + bookings: [ + { type: 'hotel', name: 'Hotel Commonwealth', ref: 'H-55210', + dates: '14–16 Sep', where: [42.3495, -71.0960], status: 'booked', source: 'user_booking' }, + { type: 'train', name: 'Amtrak 700', ref: 'A-33421', + from: 'Back Bay', to: 'Penn Station, NYC', date: '2026-09-16', + depart: '17:00', arrive: '19:40', status: 'booked', source: 'user_booking', + stations: { from: [42.3519, -71.0654], to: [40.7506, -73.9935] } } + ] + }, + days: [ + { id: 'D1', date: '2026-09-14', label: 'Day 1 · Mon 14', + startMin: 8 * 60, wakingHours: 11, + base: { name: 'Hotel Commonwealth', at: [42.3495, -71.0960] }, + legs: [], + stops: [ + { id: 'S1', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [42.3495, -71.0960], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S2', name: 'Museum of Fine Arts', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://www.mfa.org', + at: [42.3394, -71.0949], dur: 120, durConf: 3, + suggested: { value: 150, conf: 2, src: 'mfa.org' }, + price: { amount: 25, cur: '$', conf: 2, src: 'mfa.org' }, + img: ['🖼️', 'MFA Boston', 'linear-gradient(135deg,#7a4a5c,#4c2b3a)', + 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/MFA%2C_Boston%2C_MA.jpg/1280px-MFA%2C_Boston%2C_MA.jpg'], + detail: { + summary: 'One of the largest art museums in the US, next to the hotel. The Impressionist wing and the Asian galleries are the anchors — allow 2 h for highlights, 3 h if you linger in the Japanese scroll wing. Thursday/Friday the light in the skylit atrium is the best.', + links: [ + { label: 'Official site', href: 'https://www.mfa.org' }, + { label: 'Tickets', href: 'https://www.mfa.org/visit/tickets' }, + { label: 'TripAdvisor', href: 'https://www.tripadvisor.com/Attraction_Review-g1146335-d183805-Reviews-Museum_of_Fine_Arts-Boston_MA.html' } + ] } }, + { id: 'S4', name: 'Lunch — State Street Fare', kind: 'meal', mealKind: 'lunch', + slot: 'lunch', state: 'planned', url: 'https://www.statestreetfare.com', + at: [42.3410, -71.0935], dur: 60, durConf: 3, + suggested: { value: 75, conf: 2, src: 'statestreetfare.com' }, + price: { amount: 30, cur: '$', conf: 2, src: 'statestreetfare.com' }, + img: ['🍽️', 'State Street Fare', 'linear-gradient(135deg,#d08b3c,#9c5f1e)'], + detail: { + summary: 'The MFA’s own café with a garden terrace — New England seasonal plates. It’s the sensible choice after a museum morning; the terrace gets crowded by 13:00 on weekends.', + links: [ + { label: 'Official site', href: 'https://www.statestreetfare.com' }, + { label: 'Menu', href: 'https://www.statestreetfare.com/menu' } + ] } }, + { id: 'S5', name: 'Boston Public Library (Coppa Wing)', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://www.bpl.org', + at: [42.3499, -71.0769], dur: 60, durConf: 3, + suggested: { value: 60, conf: 2, src: 'bpl.org' }, + price: { amount: 0, cur: '$', conf: 2, src: 'bpl.org' }, + img: ['📚', 'BPL', 'linear-gradient(135deg,#5f7a4f,#3a4f2c)'], + detail: { + summary: 'The Beaux-Arts free reading room and the Domitian & Apollo statues in the courtyard. A good 45–60 min breather on the walk toward the North End — the architecture alone is worth it.', + links: [ + { label: 'Official site', href: 'https://www.bpl.org' } + ] } }, + { id: 'S6', name: 'Faneuil Hall & Quincy Market', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://www.boston.gov/departments/parks/faneuil-hall', + at: [42.3601, -71.0555], dur: 90, durConf: 3, + suggested: { value: 90, conf: 2, src: 'boston.gov' }, + price: null, + img: ['🏛️', 'Faneuil Hall', 'linear-gradient(135deg,#b5533c,#7a3a2c)'], + detail: { + summary: 'The “Cradle of Liberty” and the covered food market. Best worked top-down: the market stalls first (clam chowder, lemonade), then the hall’s free exhibits. Weekends are busy — September afternoons are kinder.', + links: [ + { label: 'Official site', href: 'https://www.boston.gov/departments/parks/faneuil-hall' }, + { label: 'Quincy Market', href: 'https://www.quincymarket.com' } + ] } } + ] + }, + { id: 'D2', date: '2026-09-15', label: 'Day 2 · Tue 15', + startMin: 8 * 60 + 30, wakingHours: 11, + base: { name: 'Hotel Commonwealth', at: [42.3495, -71.0960] }, + legs: [], + stops: [ + { id: 'S8', name: 'Breakfast at the hotel', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [42.3495, -71.0960], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S9', name: 'Paul Revere House', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://www.boston.gov/departments/parks/paul-revere-house', + at: [42.3616, -71.0631], dur: 45, durConf: 3, + suggested: { value: 45, conf: 2, src: 'boston.gov' }, + price: { amount: 10, cur: '$', conf: 2, src: 'boston.gov' }, + img: ['🏠', 'Paul Revere House', 'linear-gradient(135deg,#8f7a4f,#5c4d2e)'], + detail: { + summary: 'The oldest house in Boston, a tight 30–45 min with the resident docent. Timed entry; the North End around it is the walk.', + links: [ + { label: 'Official site', href: 'https://www.boston.gov/departments/parks/paul-revere-house' } + ] } }, + { id: 'S10', name: 'Lunch — Union Oyster House', kind: 'meal', mealKind: 'lunch', + slot: 'lunch', state: 'planned', url: 'https://www.unionoysterhouse.com', + at: [42.3621, -71.0604], dur: 60, durConf: 3, + suggested: { value: 75, conf: 2, src: 'unionoysterhouse.com' }, + price: { amount: 40, cur: '$', conf: 2, src: 'unionoysterhouse.com' }, + img: ['🦪', 'Union Oyster House', 'linear-gradient(135deg,#4f7a9e,#2c4f6b)'], + detail: { + summary: 'Claimed oldest restaurant in the US (1826). Clam chowder and a short, serious menu. Book ahead for the lunch hour — the room is small.', + links: [ + { label: 'Official site', href: 'https://www.unionoysterhouse.com' } + ] } }, + { id: 'S11', name: 'New England Aquarium', kind: 'visit', slot: 'visit', state: 'planned', + url: 'https://neaq.org', + at: [42.3598, -71.0483], dur: 120, durConf: 3, + suggested: { value: 120, conf: 2, src: 'neaq.org' }, + price: { amount: 35, cur: '$', conf: 2, src: 'neaq.org' }, + img: ['🐋', 'NE Aquarium', 'linear-gradient(135deg,#2f6fd6,#1d4f9c)'], + detail: { + summary: 'The whale tank and the Harborwalk beyond it. September is a good time — the new whale migration is winding down but the penguin and jelly exhibits are year-round. Allow 2 h.', + links: [ + { label: 'Official site', href: 'https://neaq.org' } + ] } } + ] + }, + { id: 'D3', date: '2026-09-16', label: 'Day 3 · Wed 16', isDeparture: true, + startMin: 8 * 60, wakingHours: 11, + base: { name: 'Hotel Commonwealth', at: [42.3495, -71.0960] }, + legs: [], + stops: [ + { id: 'S15', name: 'Breakfast & check-out', kind: 'meal', mealKind: 'breakfast', + slot: 'breakfast', state: 'planned', + at: [42.3495, -71.0960], dur: 30, durConf: 3, + suggested: { value: 30, conf: 2, src: 'tripadvisor.com' } }, + { id: 'S12', name: 'Boston Common & Public Garden', kind: 'visit', slot: 'visit', state: 'planned', + at: [42.3547, -71.0717], dur: 45, durConf: 3, + suggested: { value: 45, conf: 1, src: null }, + price: null, + img: ['🌳', 'Public Garden', 'linear-gradient(135deg,#4f9e63,#2c6b40)'] }, + { id: 'S16', name: 'Train → Penn Station NYC', kind: 'transit', slot: 'transit', state: 'planned', + at: [42.3519, -71.0654], dur: 30, durConf: 4, + transit: { name: 'Amtrak 700', ref: 'A-33421', + from: 'Back Bay', to: 'Penn Station, NYC', + depart: '17:00', arrive: '19:40', arriveBy: '16:30', + buffer: '30 min before departure' }, + suggested: null, + price: { amount: 65, cur: '$', conf: 4, src: 'booking' }, + img: ['🚄', 'Amtrak 700', 'linear-gradient(135deg,#274b8f,#16294d)'] } + ] + } + ], + candidates: { + hotel: [ + { id: 'H1', name: 'Hotel Commonwealth', at: [42.3495, -71.0960], price: 165, dur: null, + tags: ['by the mfa', 'quiet', 'refundable'], pitch: 'Where you are now — one block from the MFA, refundable until the 12th.', url: null }, + { id: 'H2', name: 'The Liberty Hotel', at: [42.3580, -71.0530], price: 240, dur: null, + tags: ['north end', 'rooftop', 'grand'], pitch: 'Rooftop bar over the North End — closer to the food, pricier.', url: null }, + { id: 'H3', name: 'Haven Guest House', at: [42.3470, -71.0900], price: 120, dur: null, + tags: ['value', 'small', 'near mfa'], pitch: 'Six rooms off the MFA’s garden — cheaper, a short walk to everything.', url: null } + ], + lunch: [ + { id: 'C1', name: 'State Street Fare', at: [42.3410, -71.0935], price: 30, dur: 75, + tags: ['by the mfa', 'garden', 'seasonal'], pitch: 'The MFA’s café — garden terrace, New England seasonal plates.', + url: 'https://www.statestreetfare.com', img: ['🍽️', 'State Street Fare', 'linear-gradient(135deg,#d08b3c,#9c5f1e)'] }, + { id: 'C2', name: 'Union Oyster House', at: [42.3621, -71.0604], price: 40, dur: 75, + tags: ['historic', 'oysters', 'book ahead'], pitch: 'Claimed oldest restaurant in the US — clam chowder, a short serious menu.', + url: 'https://www.unionoysterhouse.com', img: ['🦪', 'Union Oyster', 'linear-gradient(135deg,#4f7a9e,#2c4f6b)'] }, + { id: 'C3', name: 'Eataly Boston', at: [42.3595, -71.0540], price: 35, dur: 60, + tags: ['market', 'fast', 'central'], pitch: 'The food-hall option — a dozen kitchens, grab and go, by Quincy Market.', + url: 'https://boston.eataly.net', img: ['🍕', 'Eataly', 'linear-gradient(135deg,#b0563c,#7a3a2c)'] }, + { id: 'C4', name: 'Kazuo', at: [42.3540, -71.0620], price: 32, dur: 60, + tags: ['japanese', 'counter', 'near the common'], pitch: 'Japanese counter near the Common — quiet, good, a little pricier.', + url: 'https://kazooboston.com', img: ['🍣', 'Kazuo', 'linear-gradient(135deg,#5f6b7a,#3a434f)'] } + ], + visit: [ + { id: 'V1', name: 'John F. Kennedy Presidential Library', at: [42.3599, -71.0590], price: 15, dur: 90, + tags: ['history', 'harbor view', 'indoor'], pitch: 'The JFK archives with a harbor view — a good 90 min, indoor if it turns.', + url: 'https://www.jfklibrary.org', img: ['🎙️', 'JFK Library', 'linear-gradient(135deg,#4a5a78,#2f3a50)'] }, + { id: 'V2', name: 'Harborwalk', at: [42.3590, -71.0420], price: 0, dur: 60, + tags: ['outdoor', 'harbor', 'walk'], pitch: 'The esplanade along the harbor — sunset is the point in September.', + url: 'https://www.boston.gov/departments/parks/harborwalk', img: ['🌅', 'Harborwalk', 'linear-gradient(135deg,#c97a3c,#8a4f1a)'] }, + { id: 'V3', name: 'T. F. Green Memorial', at: [42.3530, -71.0470], price: 0, dur: 45, + tags: ['views', 'free', 'calm'], pitch: 'The memorial at the harbor’s edge — a quiet, photo-friendly 30–45 min.', + url: 'https://www.boston.gov', img: ['⛵', 'T. F. Green', 'linear-gradient(135deg,#7a8a9e,#4f5a6b)'] }, + { id: 'V4', name: 'Boston Tea Party Ships & Museum', at: [42.3555, -71.0535], price: 30, dur: 75, + tags: ['history', 'interactive', 'harbor'], pitch: 'A 75-min interactive re-enactment on the harbor — fun, a little kitsch.', + url: 'https://www.bostonteachallenge.org', img: ['🫖', 'Tea Party', 'linear-gradient(135deg,#8f4f4f,#5c2f2f)'] } + ] + }, + nudges: { + D1: [ + { id: 'N1', title: 'Lunch may be a little tight', + body: '60 min at State Street Fare — the terrace lunch usually runs ~75 min (web), and it crowds by 13:00.', + fix: { label: 'Extend to ~75 min', stop: 'S4', dur: 75, + msg: 'Lunch extended to 75 min — the afternoon slides a little later; the day still fits.' } }, + { id: 'N2', title: 'MFA: 2 h (you) vs ~2.5 h usually', + body: 'Guides suggest 2.5–3 h for the highlights. We kept your 2 h — the Impressionist wing alone runs that long.', + fix: { label: 'Keep as is' } } + ], + D2: [ + { id: 'N3', title: 'Union Oyster House at the lunch hour', + body: 'The room is small and booked solid 12:00–13:30 — a 12:00 seat beats a 12:30 walk-in.', + fix: { label: 'Keep as is' } } + ], + D3: [] + }, + multimodal: [], + vibes: [ + { id: 'v1', label: 'history & harbor · relaxed pace' }, + { id: 'v2', label: 'museums & art' }, + { id: 'v3', label: 'food-first, North End' }, + { id: 'v4', label: 'views & sunset walk' } + ], + suggestions: [ + { stop: 'S2', pitch: 'The MFA’s Impressionist wing and the Japanese scroll room — 2 h for highlights, 3 h if you linger.', + enrich: 'open 10:00–17:00 · free first Thu 4–9 · one block from the hotel' }, + { stop: 'S4', pitch: 'The MFA’s own café — the garden terrace gets crowded by 13:00 on weekends.', + enrich: 'lunch 12:00–15:00 · ~1 h · terrace seats' }, + { stop: 'S5', pitch: 'The Beaux-Arts free reading room and the courtyard’s Domitian & Apollo — a good breather toward the North End.', + enrich: 'open 10:00–18:00 · free · ~45 min' }, + { stop: 'S6', pitch: 'Work the market stalls first (chowder, lemonade), then the free hall exhibits — weekends are busy.', + enrich: 'stalls 10:00–17:00 · hall free · ~90 min' } + ] +}; + +// 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; diff --git a/mock/index.html b/mock/index.html index 643e64d..496069b 100644 --- a/mock/index.html +++ b/mock/index.html @@ -24,6 +24,11 @@
Drop a travel booking to start
ticket, flight or hotel confirmation
+
+ or explore a demo trip + + +
@@ -42,10 +47,21 @@ @@ -56,7 +72,7 @@
-
+
@@ -83,7 +99,7 @@
diff --git a/mock/server.js b/mock/server.js index 364d302..0513495 100644 --- a/mock/server.js +++ b/mock/server.js @@ -1,6 +1,7 @@ -// Mock server: static UI + local tile backend (proxy -> OSM, disk-cached). +// Mock server: static UI + local tile backend (proxy -> OSM, disk-cached) +// + routing backend (proxy -> local OSRM on :5000, NE-US extract). // The UI only ever talks to localhost; swap the upstream for a real -// tileserver-gl later without touching the frontend. +// tileserver-gl / OSRM later without touching the frontend. const http = require('http'); const fs = require('fs'); const path = require('path'); @@ -8,6 +9,8 @@ const { Readable } = require('stream'); const PORT = 8077; const ROOT = __dirname; +const OSRM = process.env.OSRM_URL || 'http://localhost:5000'; +const PROFILE = { foot: 'walking', walk: 'walking', bike: 'cycling', cycle: 'cycling', car: 'driving', drive: 'driving', transit: 'driving' }; const CACHE = path.join(__dirname, '.tilecache'); fs.mkdirSync(CACHE, { recursive: true }); @@ -19,6 +22,41 @@ const mime = { http.createServer((req, res) => { const u = new URL(req.url, 'http://x'); + // ---- routing backend (proxy -> local OSRM) ------------------------ + // /route?mode=foot&points=lng,lat;lng,lat[;...] + // /table?mode=foot&points=lng,lat;lng,lat[;...] + if (u.pathname === '/route' || u.pathname === '/table') { + const kind = u.pathname.slice(1); + const mode = u.searchParams.get('mode') || 'foot'; + const points = u.searchParams.get('points') || ''; + const profile = PROFILE[mode] || 'driving'; + let osrmPath = `/${kind}/v1/${profile}/${encodeURIComponent(points)}`; + if (kind === 'route') osrmPath += '?overview=full&alternatives=false&steps=false'; + else { + // table: durations from the first point (the anchor) to every point + const n = points.split(';').filter(Boolean).length; + const targets = Array.from({ length: n }, (_, i) => i).join(','); + osrmPath += `?annotations=duration,distance&sources=0&targets=${targets}`; + } + const fetchUp = fetch(OSRM + osrmPath).then(r => r.text()).catch(() => null); + fetchUp.then(body => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Content-Type', 'application/json'); + if (body == null) { res.writeHead(502); return res.end(JSON.stringify({ code: 'ProxyError' })); } + res.writeHead(200); res.end(body); + }); + return; + } + // routing availability probe (so the UI can label router vs estimate) + if (u.pathname === '/router-status') { + const probe = '-71.06,42.35;-71.07,42.36'; // must span >0 distance for the health check + fetch(OSRM + '/route/v1/walking/' + probe + '?overview=false').then(r => r.text()) + .then(t => { let ok = false; try { ok = JSON.parse(t).code === 'Ok' && JSON.parse(t).routes?.[0]?.distance > 0; } catch (e) {} + res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: ok, osrm: OSRM })); }) + .catch(() => { res.setHeader('Content-Type', 'application/json'); res.writeHead(200); res.end(JSON.stringify({ router: false, osrm: OSRM })); }); + return; + } + // ---- tile backend ------------------------------------------------- if (u.pathname.startsWith('/tiles/')) { const parts = u.pathname.split('/'); diff --git a/mock/styles.css b/mock/styles.css index f7a3c46..66739ce 100644 --- a/mock/styles.css +++ b/mock/styles.css @@ -53,7 +53,8 @@ button { font: inherit; } /* ---------------- app grid ---------------- */ #app { position: fixed; top: 46px; left: 0; right: 0; bottom: 0; z-index: 10; display: grid; - grid-template-columns: 354px 1fr 350px; } + grid-template-columns: 354px 1fr 350px; + pointer-events: none; } /* map underneath must receive drags/clicks; children opt back in */ #rail, #chat { min-height: 0; pointer-events: auto; } #rail { background: var(--card); border-right: 1px solid var(--line); display: flex; flex-direction: column; overflow: hidden; } #rail-body, #chat-body { min-height: 0; } @@ -356,6 +357,65 @@ button { font: inherit; } border: 2.5px solid #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4); transition: transform .15s; } .mkd { background: #fff; color: #8b93a3; border: 2px dashed #9aa3b2; box-shadow: none; } +/* topbar width relief: drop the verbose badges first */ +@media (max-width: 1560px) { #offline-badge { display: none; } } +@media (max-width: 1320px) { #nudge-badge { display: none; } .crumb { display: none; } } + +/* ---------------- landing demo chips ---------------- */ +.land-demo { display: flex; align-items: center; gap: 8px; margin-top: 16px; font-size: 12px; color: var(--ink2); flex-wrap: wrap; } +.land-demo .chip { font-size: 12px; padding: 6px 12px; } +.land-demo .chip i { font-style: normal; font-size: 10px; opacity: .75; } + +/* ---------------- scope toggle (Day | Trip) ---------------- */ +.scope-toggle { display: flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; margin-left: 12px; } +.sct { border: 0; background: #fff; padding: 4px 12px; font-size: 12px; font-weight: 600; color: var(--ink2); cursor: pointer; } +.sct + .sct { border-left: 1px solid var(--line); } +.sct:hover { color: var(--accent); } +.sct.on { background: #f3ede9; color: var(--accent); } + +/* ---------------- version pill + history ---------------- */ +.verpill { display: flex; align-items: center; gap: 2px; background: #f2f4f8; border: 1px solid var(--line); + border-radius: 999px; padding: 2px 4px; position: relative; } +.vp-btn { border: 0; background: none; width: 24px; height: 24px; border-radius: 50%; cursor: pointer; + font-size: 12px; color: var(--ink); } +.vp-btn:hover { background: #e4e8ef; } +.vp-btn.off { opacity: .3; cursor: default; } +.vp-btn.off:hover { background: none; } +.vp-label { font-size: 12px; font-weight: 700; color: var(--ink2); cursor: pointer; padding: 0 6px; font-variant-numeric: tabular-nums; } +.vp-label:hover { color: var(--accent); } +.verlist { position: absolute; top: 52px; right: 16px; z-index: 50; width: 340px; max-height: 60vh; overflow-y: auto; + background: var(--card); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow); padding: 6px; } +.ver-row { padding: 8px 10px; border-radius: 10px; cursor: pointer; font-size: 12px; line-height: 1.4; } +.ver-row:hover { background: #f2f4f8; } +.ver-row.cur { background: #f3ede9; } +.vr-main { color: var(--ink); } +.vr-cur { font-size: 10px; color: var(--accent); font-weight: 700; } +.vr-diff { color: var(--ink2); font-size: 11px; margin-top: 3px; } + +/* ---------------- L0 trip overview ---------------- */ +.trip-head { padding: 4px 2px 10px; } +.trip-title { font-size: 16px; font-weight: 800; } +.trip-sub { font-size: 11.5px; color: var(--ink2); margin-top: 3px; } +.trip-day { border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; margin: 8px 0; cursor: pointer; + background: var(--card); transition: border-color .15s, box-shadow .15s; } +.trip-day:hover { border-color: #c9cfda; box-shadow: 0 2px 8px rgba(20,24,35,.06); } +.trip-day.cur { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(181,83,60,.12); } +.td-top { display: flex; justify-content: space-between; align-items: baseline; } +.td-label { font-weight: 700; font-size: 13px; } +.td-end { font-size: 11px; color: var(--ink2); font-variant-numeric: tabular-nums; } +.td-bar { height: 6px; background: #e7ebf2; border-radius: 99px; overflow: hidden; margin: 7px 0 6px; } +.td-fill { height: 100%; background: var(--ok); border-radius: 99px; transition: width .5s ease; } +.td-fill.warn { background: var(--warn); } +.td-meta { display: flex; gap: 8px; align-items: center; font-size: 11.5px; color: var(--ink2); flex-wrap: wrap; } +.td-names { font-size: 11px; color: var(--ink2); margin-top: 5px; opacity: .85; } + +/* ---------------- offline bundle card ---------------- */ +.offline-card { border: 1px solid var(--line); border-radius: 12px; padding: 11px 13px; margin: 12px 0 6px; background: #f7f9fc; } +.oc-title { font-weight: 700; font-size: 12.5px; } +.oc-sub { font-size: 11.5px; color: var(--ink2); margin-top: 5px; line-height: 1.5; } +.oc-progress { font-family: ui-monospace, Menlo, monospace; font-size: 11px; } +.offline-card .btn { margin-top: 9px; } + /* ---------------- mobile: single pane + bottom tab bar ---------------- */ #mobiletabs { display: none; } @media (max-width: 860px) { @@ -381,6 +441,9 @@ button { font: inherit; } .msg { font-size: 13px; padding: 9px 11px; } #chat-input-row { padding: 10px; } .tc { font-size: 11px; } + .scope-toggle { margin-left: 8px; } + .verpill { transform: scale(.92); } + .verlist { width: min(320px, calc(100vw - 24px)); right: 8px; } /* map overlays */ #l3-editor { left: 8px; right: 8px; width: auto; bottom: 10px; } #discover { left: 8px; right: 8px; top: auto; bottom: 8px; width: auto; max-height: 62%; }