mock: wire to the real local OSRM router + L0 trip view, version history, offline cues

- 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
This commit is contained in:
Greg Pomerantz 2026-09-09 00:47:50 -04:00
parent 146c7e168c
commit 30f3dd9cbf
5 changed files with 735 additions and 80 deletions

View File

@ -21,6 +21,80 @@ const deep = o => JSON.parse(JSON.stringify(o));
const WALK_KMH = 4.6; const WALK_KMH = 4.6;
const walkMin = (a, b) => Math.max(1, Math.round(haversine(a, b) / 1000 / (WALK_KMH / 60))); 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 Googles 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 ---------------- // ---------------- state ----------------
const days = {}; const days = {};
M.days.forEach(d => days[d.id] = deep(d)); M.days.forEach(d => days[d.id] = deep(d));
@ -78,7 +152,8 @@ function renderHotelMks() {
let hotelMks = [], trainMks = []; let hotelMks = [], trainMks = [];
function initMap() { 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.control.zoom({ position: 'bottomright' }).addTo(map);
L.tileLayer('/tiles/{z}/{x}/{y}.png', { L.tileLayer('/tiles/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors · local tile proxy :8077', maxZoom: 19 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 if (d.isDeparture) return 0; // departure day: the train is the end of the day
const last = d.stops[d.stops.length - 1]; const last = d.stops[d.stops.length - 1];
if (!last) return 0; 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); const list = staysForDay(d);
// worst case across every candidate stay for these nights (equals the real walk when one) // 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))); 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) { function rebuildLegs(d = day) {
d.legs = []; d.legs = [];
delete d.retRouted;
for (let i = 0; i < d.stops.length - 1; i++) { for (let i = 0; i < d.stops.length - 1; i++) {
const a = d.stops[i], b = d.stops[i + 1]; const a = d.stops[i], b = d.stops[i + 1];
// a vague (region) stop at either end → worst-case leg until refined // a vague (region) stop at either end → worst-case leg until refined
if (a.kind === 'region' || b.kind === 'region') { if (a.kind === 'region' || b.kind === 'region') {
d.legs.push({ mode: 'foot', dur: 17, conf: 1, vague: true }); 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 { } else {
let h = 0; const key = a.name + '→' + b.name; const mm = (M.multimodal || []).find(x => x.into === b.id);
for (const c of key) h = (h * 31 + c.charCodeAt(0)) % 997; if (mm) {
d.legs.push({ mode: 'foot', dur: 8 + h % 15, conf: 3 }); 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 ---------------- // ---------------- vague / region placeholder stops ----------------
@ -182,14 +265,18 @@ function renderMap() {
return; return;
} }
const pts = obj.stops.map(s => s.at); const pts = obj.stops.map(s => s.at);
for (let i = 0; i < pts.length - 1; i++) for (let i = 0; i < pts.length - 1; i++) {
legEls[i] = L.polyline([pts[i], pts[i + 1]], { // 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', color: obj.legs[i] && obj.legs[i].mode === 'multimodal' ? '#2f6fd6' : '#2e9e5b',
weight: 5, opacity: .85 weight: 5, opacity: .85
}).addTo(map); }).addTo(map);
}
if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row) if (pts.length && !obj.isDeparture) { // return leg to the hotel (hoverable via its rail row)
staysForDay(obj).forEach(o => { 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); otherLayers.push(retLine);
retLines.push(retLine); retLines.push(retLine);
}); });
@ -207,12 +294,15 @@ function renderMap() {
}); });
}); });
const cur = days[curDay]; 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 discOpen = !$('#discover').classList.contains('hidden');
const pad = matchMedia('(max-width: 860px)').matches const pad = matchMedia('(max-width: 860px)').matches
? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width ? { paddingTopLeft: [12, 96], paddingBottomRight: [12, 70] } // mobile: map is full-width
: { paddingTopLeft: [370, 70], paddingBottomRight: [discOpen ? 730 : 420, 70] }; : { 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) { 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); if (at >= 0) day.stops.splice(at, 0, stop); else day.stops.push(stop);
} }
rebuildLegs(); rebuildLegs();
commit(`${stop.name}${stateLabel(state)}${incumbent ? ' (swapped for ' + incumbent.name + ')' : ''}`);
renderAll(flash ? [stop.id] : []); 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 }; 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, 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 }); checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'booked', price: c.price });
renderHotelMks(); renderBaseChip(); renderHotelMks(); renderBaseChip();
closeDiscover(); renderAll(); closeDiscover(); commit(`hotel → ${c.name}`); renderAll();
ai(`Now staying at <b>${c.name}</b> for ${stayRange(cur)} — those nights re-anchor on it, and the “back to hotel” legs dropped from worst-case to real times.`, 700); ai(`Now staying at <b>${c.name}</b> for ${stayRange(cur)} — those nights re-anchor on it, and the “back to hotel” legs dropped from worst-case to real times.`, 700);
return; return;
} }
@ -460,7 +551,7 @@ function holdIdea(c, slot) {
stays.push({ id: 'ST' + (++staySeq), name: c.name, at: c.at, place: cur.place, 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 }); checkIn: cur.checkIn, checkOut: cur.checkOut, state: 'alt', price: c.price });
renderHotelMks(); renderBaseChip(); renderHotelMks(); renderBaseChip();
closeDiscover(); renderAll(); closeDiscover(); commit(`hotel option: ${c.name}`); renderAll();
ai(`Keeping <b>${c.name}</b> 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); ai(`Keeping <b>${c.name}</b> 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; return;
} }
@ -662,7 +753,7 @@ function renderStopCard(body, s, i, flashIds) {
else ai(`Dropped <b>${cand.name}</b> into ${dayLabel()}.`, 500); else ai(`Dropped <b>${cand.name}</b> into ${dayLabel()}.`, 500);
} else { } else {
day.stops.push(makeStop(cand, slot, 'maybe')); day.stops.push(makeStop(cand, slot, 'maybe'));
rebuildLegs(); renderAll([cand.id]); rebuildLegs(); commit(`idea: ${cand.name}`); renderAll([cand.id]);
ai(`Dropped <b>${cand.name}</b> into ${dayLabel()} as an idea (different slot) — promote it whenever you like.`, 500); ai(`Dropped <b>${cand.name}</b> into ${dayLabel()} as an idea (different slot) — promote it whenever you like.`, 500);
} }
if (disc) closeDiscover(); if (disc) closeDiscover();
@ -673,6 +764,7 @@ function renderStopCard(body, s, i, flashIds) {
const [moved] = day.stops.splice(from, 1); const [moved] = day.stops.splice(from, 1);
day.stops.splice(i, 0, moved); day.stops.splice(i, 0, moved);
rebuildLegs(); rebuildLegs();
commit(`moved ${moved.name}`);
renderAll([moved.id]); renderAll([moved.id]);
}); });
c.addEventListener('mouseenter', () => highlightStop(s.id, true)); c.addEventListener('mouseenter', () => highlightStop(s.id, true));
@ -739,7 +831,7 @@ function setState(s, state) {
if (cur) { cur.state = 'alt'; ai(`Swapped: <b>${s.name}</b> is now your ${s.slot}; <b>${cur.name}</b> becomes the backup.`, 600); } if (cur) { cur.state = 'alt'; ai(`Swapped: <b>${s.name}</b> is now your ${s.slot}; <b>${cur.name}</b> becomes the backup.`, 600); }
} }
s.state = state; 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'))) if (!(state === 'planned' && day.stops.find(x => x.slot === s.slot && x !== s && x.state === 'alt')))
ai(`<b>${s.name}</b> is now “${stateLabel(state)}”.`, 400); ai(`<b>${s.name}</b> is now “${stateLabel(state)}”.`, 400);
} }
@ -748,7 +840,7 @@ function moveStopToDay(s, targetId) {
const t = days[targetId]; const t = days[targetId];
t.stops.push(s); t.stops.push(s);
rebuildLegs(); if (t !== day) rebuildLegs(t); rebuildLegs(); if (t !== day) rebuildLegs(t);
renderAll(); commit(`${s.name}${t.label}`); renderAll();
ai(`Moved <b>${s.name}</b> to ${t.label} — legs and times re-timed there.`, 500); ai(`Moved <b>${s.name}</b> to ${t.label} — legs and times re-timed there.`, 500);
} }
const dayLabel = () => M.days.find(d => d.id === day.id).label; const dayLabel = () => M.days.find(d => d.id === day.id).label;
@ -771,26 +863,9 @@ function renderBudget() {
} }
// ---------------- nudges (issues, but gentle) ---------------- // ---------------- nudges (issues, but gentle) ----------------
const nudgesByDay = { // Data-driven (M.nudges); a runtime copy carries the per-day `fixed` flags.
D1: [ const nudgesByDay = deep(M.nudges || {});
{ id: 'N1', fixed: false, Object.values(nudgesByDay).forEach(list => list.forEach(n => n.fixed = false));
title: 'Lunch may be a little tight',
body: '45 min at Trattoria Mario — a meal there usually runs <b>~90 min</b> <i>(web)</i>, 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 23 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 theres a fallback if its full.',
fix: { label: 'Keep as is', apply() {} } }
]
};
function activeNudges() { return (nudgesByDay[day.id] || []).filter(n => !n.fixed); } function activeNudges() { return (nudgesByDay[day.id] || []).filter(n => !n.fixed); }
function renderNudges() { function renderNudges() {
const p = $('#nudges'); if (!p) return; p.innerHTML = ''; const p = $('#nudges'); if (!p) return; p.innerHTML = '';
@ -802,7 +877,9 @@ function renderNudges() {
d.append(el('div', 'nb', n.body)); d.append(el('div', 'nb', n.body));
const b = el('button', 'btn ghost small', n.fix.label); const b = el('button', 'btn ghost small', n.fix.label);
b.onclick = () => { 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(); renderAll();
if (n.fix.msg) ai(n.fix.msg, 300); 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: '<div style="width:16px;height:16px;border-radius:50%;background:#b5533c;border:3px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.5)"></div>', iconSize: [16, 16], iconAnchor: [8, 8] }) }).addTo(layer); const mk = p => L.marker(p, { draggable: true, icon: L.divIcon({ className: 'wp', html: '<div style="width:16px;height:16px;border-radius:50%;background:#b5533c;border:3px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.5)"></div>', iconSize: [16, 16], iconAnchor: [8, 8] }) }).addTo(layer);
const m1 = mk(a), m2 = mk(mid), m3 = mk(b); const m1 = mk(a), m2 = mk(mid), m3 = mk(b);
const box = $('#l3-editor .l3-time'); const box = $('#l3-editor .l3-time');
let rt = 0; // debounce token for the router round-trip
const upd = () => { const upd = () => {
const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng(); const p1 = m1.getLatLng(), p2 = m2.getLatLng(), p3 = m3.getLatLng();
line.setLatLngs([[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]]); 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]); 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 leg.dur = Math.max(4, Math.round(dist / 4800 * 60)); // instant straight-line estimate
box.innerHTML = 'leg time: '; box.append(tchip(leg.dur, 3)); leg.conf = 1;
box.innerHTML = 'leg time: est. '; box.append(tchip(leg.dur, 1));
renderBudget(); 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)); [m1, m2, m3].forEach(m => m.on('drag', upd));
$('#l3-title').textContent = `${day.stops[legIdx].name}${day.stops[legIdx + 1].name}`; $('#l3-title').textContent = `${day.stops[legIdx].name}${day.stops[legIdx + 1].name}`;
@ -961,6 +1048,7 @@ function openL3(legIdx) {
const obs = setInterval(() => { const obs = setInterval(() => {
if (!$('#l3-editor').classList.contains('hidden')) return; if (!$('#l3-editor').classList.contains('hidden')) return;
clearInterval(obs); clearInterval(obs);
commit('edited a leg route');
renderAll(); renderAll();
}, 250); }, 250);
} }
@ -970,6 +1058,79 @@ function closeL3() {
} }
$('#l3-close').onclick = 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 <b>v${s.v}</b> — ${s.label}. Later edits are gone from the branch; redo cant 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', `<b>v${s.v}</b> · ${when} · ${s.label}` + (i === hIdx ? ' <span class="vr-cur">current</span>' : '')));
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 ---------------- // ---------------- day tabs ----------------
function buildDayTabs() { function buildDayTabs() {
const tabs = $('#daytabs'); tabs.innerHTML = ''; const tabs = $('#daytabs'); tabs.innerHTML = '';
@ -1001,7 +1162,107 @@ function setDay(id) {
if (disc) closeDiscover(); // the drawers anchor is day-specific if (disc) closeDiscover(); // the drawers anchor is day-specific
renderAll(); 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 ---------------- // ---------------- chat ----------------
function msg(role, html) { function msg(role, html) {
@ -1046,15 +1307,18 @@ function handleUser(v) {
} }
if (s.includes('later') || s.includes('morning')) { if (s.includes('later') || s.includes('morning')) {
day.startMin += 30; day.startMin += 30;
commit('start day 30 min later');
renderAll(day.stops.map(x => x.id)); renderAll(day.stops.map(x => x.id));
ai(`Pushed ${dayLabel()} back 30 min — everything re-timed. Day now ends at <b>${fmt(layout())}</b>.`); ai(`Pushed ${dayLabel()} back 30 min — everything re-timed. Day now ends at <b>${fmt(layout())}</b>.`);
} else if (s.includes('lunch') || s.includes('longer')) { } else if (s.includes('lunch') || s.includes('longer')) {
const n = activeNudges()[0]; const n = activeNudges().find(x => x.fix?.stop && x.fix?.dur);
const lunch = day.stops.find(x => x.slot === 'lunch' && x.state === 'planned'); const st = n ? stopById(n.fix.stop) : null;
if (n && !n.fixed && lunch) { if (n && !n.fixed && st) {
n.fixed = true; lunch.dur = 90; n.fixed = true; st.dur = n.fix.dur;
renderAll([lunch.id]); commit(`nudge: ${n.fix.label}`);
ai(`Done — lunch now runs 90 min. What follows it slides to ${fmt(day.stops[day.stops.indexOf(lunch) + 1]?.start ?? 0)}.`); 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(`Theres nothing to loosen on ${dayLabel()} right now — the day looks good.`); } else ai(`Theres nothing to loosen on ${dayLabel()} right now — the day looks good.`);
} else { } 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 cards <b>alternatives</b> or drag a candidate straight onto the plan.', 900); 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 cards <b>alternatives</b> 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()); $('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend());
// ---------------- landing → parse → plan ---------------- // ---------------- landing → parse → plan ----------------
const SAMPLE = [ // parsed-booking preview, derived from the active dataset (city-aware)
['type', 'Hotel reservation', 'lodging — anchors mornings and evenings', 'hi'], function sampleRows() {
['name', 'Hotel Palagio, Florence', 'geocoded → 20 m from SMN station', 'hi'], const hb = M.trip.bookings.find(b => b.type === 'hotel');
['dates', '12 15 Sep 2026', '3 nights', 'hi'], const tb = M.trip.bookings.find(b => b.type === 'train');
['window', 'check-in 15:00 · check-out 11:00', 'parsed from e-mail body', 'mid'], const rows = [
['ref', 'Confirmation H-77821', 'read from footer image — worth a double-check', 'mid'] ['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() { function showParse() {
const t = $('#parse-table'); t.innerHTML = ''; const t = $('#parse-table'); t.innerHTML = '';
SAMPLE.forEach(([k, v, note, c]) => { sampleRows().forEach(([k, v, note, c]) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = `<td>${k}</td><td><b>${v}</b><span class="conf ${c}">${c === 'hi' ? 'confirmed' : 'verify'}</span><div style="font-size:11px;color:var(--ink2)">${note}</div></td>`; tr.innerHTML = `<td>${k}</td><td><b>${v}</b><span class="conf ${c}">${c === 'hi' ? 'confirmed' : 'verify'}</span><div style="font-size:11px;color:var(--ink2)">${note}</div></td>`;
t.append(tr); t.append(tr);
@ -1092,10 +1363,12 @@ function startApp() {
$('#landing').classList.add('hidden'); $('#landing').classList.add('hidden');
$('#topbar').classList.remove('hidden'); $('#topbar').classList.remove('hidden');
$('#app').classList.remove('hidden'); $('#app').classList.remove('hidden');
$('#crumb').textContent = M.trip.title;
renderBaseChip(); renderBaseChip();
buildDayTabs(); buildDayTabs();
if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint if (day.stops.length > 1 && !day.legs.length) rebuildLegs(day); // legs existed in data: build before first paint
renderAll(); renderAll();
commit('initial plan');
script1(); script1();
} }
@ -1171,6 +1444,7 @@ function addStopToDay(id, requested) {
copy.state = state; copy.state = state;
day.stops.push(copy); day.stops.push(copy);
rebuildLegs(); rebuildLegs();
commit(`${copy.name}${stateLabel(state)}`);
renderAll([id]); renderAll([id]);
return state; return state;
} }
@ -1178,22 +1452,22 @@ function addStopToDay(id, requested) {
function script1() { function script1() {
setTimeout(() => { setTimeout(() => {
scriptBusy = true; scriptBusy = true;
ai('Anchored your stay — <b>Hotel Palagio, 1215 Sep</b>; the 15ths 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 — <b>${b0.name}, ${stayRange(b0)}</b>; 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(() => { setTimeout(() => {
const m = msg('ai', 'Pick a vibe — or tell me in your own words:'); const m = msg('ai', 'Pick a vibe — or tell me in your own words:');
const chips = el('div', 'chips'); const chips = el('div', 'chips');
M.vibes.forEach(v => { M.vibes.forEach(v => {
const c = el('button', 'chip', v.label); 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); chips.append(c);
}); });
m.append(chips); m.append(chips);
}, 1800); }, 1800);
}, 300); }, 300);
} }
function script2() { function script2(vibe) {
scriptBusy = true; 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(() => { setTimeout(() => {
const m = msg('ai', ''); const m = msg('ai', '');
const cards = el('div', 'cards'); const cards = el('div', 'cards');
@ -1270,15 +1544,10 @@ function script2() {
} }
function script3() { function script3() {
setTimeout(() => { setTimeout(() => {
const rank = { S1: 0, S2: 1, S4: 2, S7: 2.5, S5: 3, S6: 3.5 }; rebuildLegs(day);
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();
renderAll(day.stops.map(s => s.id)); renderAll(day.stops.map(s => s.id));
ai('Day sequenced, gelato after the gardens. <b>' + ((layout() - day.startMin) / 60).toFixed(1) + ' h</b> 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 — <b>${(st.dur / 60).toFixed(1)} h</b> of ${day.wakingHours} h waking, ending ~${fmt(st.end)}. Anything that looks tight shows up as a nudge in the rail.`, 1100);
}, 1400); }, 1400);
} }
@ -1288,13 +1557,16 @@ const dz = $('#dropzone');
['dragleave', 'drop'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove('over'); })); ['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.addEventListener('drop', e => { if (e.dataTransfer?.files?.length) showParse(); });
$('#dz-sample').onclick = showParse; $('#dz-sample').onclick = showParse;
$('#dest-go').onclick = () => { $('#dest-go').onclick = () => { showParse(); };
const v = $('#dest').value.trim();
if (!v) { showParse(); return; }
SAMPLE[1] = ['name', v, 'geocoded', 'hi'];
showParse();
};
$('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').click()); $('#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 // drop a candidate onto the plan panel itself → hold it as an idea
(function () { (function () {
@ -1308,10 +1580,15 @@ $('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').c
if (!found) return; if (!found) return;
const { c, slot } = found; dragCand = null; const { c, slot } = found; dragCand = null;
day.stops.push(makeStop(c, slot, 'maybe')); day.stops.push(makeStop(c, slot, 'maybe'));
rebuildLegs(); renderAll([c.id]); rebuildLegs(); commit(`idea: ${c.name}`); renderAll([c.id]);
if (disc) closeDiscover(); if (disc) closeDiscover();
ai(`Dropped <b>${c.name}</b> into ${dayLabel()} as an idea — promote it whenever you like.`, 500); ai(`Dropped <b>${c.name}</b> into ${dayLabel()} as an idea — promote it whenever you like.`, 500);
}); });
})(); })();
initMap(); 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';
});

View File

@ -2,7 +2,11 @@
// Each stop: day, slot (mutual-exclusion group), state: planned | maybe | alt. // 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 // Only one `planned` stop per slot per day; `alt`/`maybe` don't count against
// the day's time budget. // 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: { trip: {
id: 'trip-2026-09', id: 'trip-2026-09',
version: 1, 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 <b>~90 min</b> <i>(web)</i>, 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 23 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 theres a fallback if its full.',
fix: { label: 'Keep as is' } }
]
},
multimodal: [
{ into: 'S5', dur: 18, sub: [{ mode: 'foot', dur: 6 }, { mode: 'tram', dur: 12 }] }
],
vibes: [ vibes: [
{ id: 'v1', label: 'art & food · relaxed pace' }, { id: 'v1', label: 'art & food · relaxed pace' },
{ id: 'v2', label: 'gardens & walking' }, { id: 'v2', label: 'gardens & walking' },
@ -262,3 +286,240 @@ window.MOCK = {
enrich: 'lunch from 12:30 · book ahead in season' } 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 1416 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: '1416 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 MFAs own café with a garden terrace — New England seasonal plates. Its 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 4560 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 halls 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 3045 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 MFAs 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 MFAs 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 harbors edge — a quiet, photo-friendly 3045 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 <b>~75 min</b> <i>(web)</i>, 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.53 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:0013: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 MFAs Impressionist wing and the Japanese scroll room — 2 h for highlights, 3 h if you linger.',
enrich: 'open 10:0017:00 · free first Thu 49 · one block from the hotel' },
{ stop: 'S4', pitch: 'The MFAs own café — the garden terrace gets crowded by 13:00 on weekends.',
enrich: 'lunch 12:0015:00 · ~1 h · terrace seats' },
{ stop: 'S5', pitch: 'The Beaux-Arts free reading room and the courtyards Domitian & Apollo — a good breather toward the North End.',
enrich: 'open 10:0018: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:0017: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;

View File

@ -24,6 +24,11 @@
<div class="dz-text"><b>Drop a travel booking to start</b><br>ticket, flight or hotel confirmation</div> <div class="dz-text"><b>Drop a travel booking to start</b><br>ticket, flight or hotel confirmation</div>
<button id="dz-sample" class="btn ghost small">or start from an example booking</button> <button id="dz-sample" class="btn ghost small">or start from an example booking</button>
</div> </div>
<div class="land-demo">
<span>or explore a demo trip</span>
<button class="chip" data-city="florence">Florence</button>
<button class="chip" data-city="boston">Boston <i>· live road routing</i></button>
</div>
</div> </div>
</section> </section>
@ -42,10 +47,21 @@
<!-- ======================= APP ======================= --> <!-- ======================= APP ======================= -->
<header id="topbar" class="hidden"> <header id="topbar" class="hidden">
<div class="tb-left"> <div class="tb-left">
<span class="crumb" id="crumb">Florence · 1215 Sep</span> <span class="crumb" id="crumb"></span>
<div class="scope-toggle" title="Day = working view · Trip = macro overview of all days">
<button id="scope-day" class="sct on" data-sc="day">Day</button>
<button id="scope-trip" class="sct" data-sc="trip">Trip</button>
</div>
<div class="daytabs" id="daytabs"></div> <div class="daytabs" id="daytabs"></div>
</div> </div>
<div class="tb-right"> <div class="tb-right">
<div class="verpill" id="verpill">
<button id="ver-undo" class="vp-btn off" title="Undo (Ctrl+Z)"></button>
<span id="verlabel" class="vp-label" title="Version history — recent edits, click to compare/revert">v0</span>
<button id="ver-redo" class="vp-btn off" title="Redo (Ctrl+Shift+Z)"></button>
</div>
<div id="verlist" class="verlist hidden"></div>
<span class="badge" id="offline-badge" title="On-trip offline bundle">📶 online</span>
<span class="badge" id="nudge-badge" title="Plan nudges">✓ looking good</span> <span class="badge" id="nudge-badge" title="Plan nudges">✓ looking good</span>
</div> </div>
</header> </header>
@ -56,7 +72,7 @@
<div class="rail-block" id="transit-block"></div> <div class="rail-block" id="transit-block"></div>
<div class="rail-block" id="sugg-block"></div> <div class="rail-block" id="sugg-block"></div>
<div id="rail-body"></div> <div id="rail-body"></div>
<div class="rail-foot"> <div class="rail-foot" id="rail-foot">
<div class="budget"> <div class="budget">
<div class="budget-label"><span id="budget-text"></span></div> <div class="budget-label"><span id="budget-text"></span></div>
<div class="budget-bar"><div id="budget-fill"></div></div> <div class="budget-bar"><div id="budget-fill"></div></div>
@ -83,7 +99,7 @@
</div> </div>
<div id="l3-editor" class="hidden"> <div id="l3-editor" class="hidden">
<div class="l3-title" id="l3-title">Route</div> <div class="l3-title" id="l3-title">Route</div>
<div class="l3-hint">Drag the ● waypoint (or the endpoints) — the duration updates live.</div> <div class="l3-hint">Drag the ● waypoint (or the endpoints) — the duration updates live; the local router re-routes on the road network when the area is covered.</div>
<div class="l3-time">leg time: <span id="l3-time" class="tc t-computed"></span></div> <div class="l3-time">leg time: <span id="l3-time" class="tc t-computed"></span></div>
<button class="btn ghost small" id="l3-close">Done</button> <button class="btn ghost small" id="l3-close">Done</button>
</div> </div>

View File

@ -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 // 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 http = require('http');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@ -8,6 +9,8 @@ const { Readable } = require('stream');
const PORT = 8077; const PORT = 8077;
const ROOT = __dirname; 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'); const CACHE = path.join(__dirname, '.tilecache');
fs.mkdirSync(CACHE, { recursive: true }); fs.mkdirSync(CACHE, { recursive: true });
@ -19,6 +22,41 @@ const mime = {
http.createServer((req, res) => { http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'); 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 ------------------------------------------------- // ---- tile backend -------------------------------------------------
if (u.pathname.startsWith('/tiles/')) { if (u.pathname.startsWith('/tiles/')) {
const parts = u.pathname.split('/'); const parts = u.pathname.split('/');

View File

@ -53,7 +53,8 @@ button { font: inherit; }
/* ---------------- app grid ---------------- */ /* ---------------- app grid ---------------- */
#app { position: fixed; top: 46px; left: 0; right: 0; bottom: 0; z-index: 10; display: 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, #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 { background: var(--card); border-right: 1px solid var(--line); display: flex; flex-direction: column; overflow: hidden; }
#rail-body, #chat-body { min-height: 0; } #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; } 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; } .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 ---------------- */ /* ---------------- mobile: single pane + bottom tab bar ---------------- */
#mobiletabs { display: none; } #mobiletabs { display: none; }
@media (max-width: 860px) { @media (max-width: 860px) {
@ -381,6 +441,9 @@ button { font: inherit; }
.msg { font-size: 13px; padding: 9px 11px; } .msg { font-size: 13px; padding: 9px 11px; }
#chat-input-row { padding: 10px; } #chat-input-row { padding: 10px; }
.tc { font-size: 11px; } .tc { font-size: 11px; }
.scope-toggle { margin-left: 8px; }
.verpill { transform: scale(.92); }
.verlist { width: min(320px, calc(100vw - 24px)); right: 8px; }
/* map overlays */ /* map overlays */
#l3-editor { left: 8px; right: 8px; width: auto; bottom: 10px; } #l3-editor { left: 8px; right: 8px; width: auto; bottom: 10px; }
#discover { left: 8px; right: 8px; top: auto; bottom: 8px; width: auto; max-height: 62%; } #discover { left: 8px; right: 8px; top: auto; bottom: 8px; width: auto; max-height: 62%; }