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 = `