')) continue;
const t = e.text.length > 3000 ? e.text.slice(0, 3000) + ' […]' : e.text;
out.unshift({ role: e.role === 'assistant' ? 'assistant' : 'user', content: t });
budget -= t.length;
}
return out;
}
const commit = (label) => {
// invariant for every edit, not just flight re-anchoring: the date-carrying
// neighbours (stays, places, hotel booking strings) must track the day
// dates. Idempotent — a consistent plan is a no-op. Runs before the
// snapshot so history stores consistent pairs.
redateStaysAndPlaces();
history = history.slice(0, hIdx + 1); // drop any redo branch
history.push({ v: ++version, label, at: Date.now(), days: deep(days), stays: deep(stays), bookings: deep(M.trip.bookings), title: M.trip.title });
hIdx = history.length - 1;
if (history.length > 60) { history.shift(); hIdx--; }
renderVersionPill();
if (typeof persistTrip === 'function') persistTrip();
};
function restore(idx, silent) {
const s = history[idx]; if (!s) return;
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);
if (s.bookings) M.trip.bookings = deep(s.bookings);
if (s.title != null) M.trip.title = s.title;
redateStaysAndPlaces(); // old-era snapshots may hold inconsistent pairs
closeDiscover(); closeL3();
rebuildLegs(day); // snapshots may predate route enrichment — re-upgrade legs
renderHotelMks(); renderModeToggle();
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 = '';
dayList().forEach(d => { // live days — M.days holds the pre-edit originals
const t = el('button', 'daytab' + (d.id === curDay ? ' sel' : ''), d.label);
t.onclick = () => setDay(d.id);
t.addEventListener('dragover', e => { e.preventDefault(); t.classList.add('over'); });
t.addEventListener('dragleave', () => t.classList.remove('over'));
t.addEventListener('drop', e => {
e.preventDefault(); t.classList.remove('over');
const i = parseInt(e.dataTransfer.getData('text/plain'), 10);
const s = day.stops[i]; if (!s) return;
day.stops.splice(i, 1);
const tDay = days[d.id];
tDay.stops.push(s);
rebuildLegs(); if (tDay !== day) rebuildLegs(tDay);
setDay(curDay);
ai(`Moved ${s.name} to ${tDay.label}.`, 400);
});
tabs.append(t);
});
}
function setDay(id) {
curDay = id; day = days[id];
// route this day's legs in the background (idempotent — already-routed legs are
// skipped); without this a day opened from a saved plan would sit on straight-line
// estimates (notably the new return leg) until something else enriched it
if (!day.legs.length) rebuildLegs(day); else enrichLegs(day);
document.querySelectorAll('.daytab').forEach((t, i) => t.classList.toggle('sel', dayList()[i] && dayList()[i].id === id));
closeL3(); // a selected route from another day is stale
tempClear();
if (disc) closeDiscover(); // the drawer’s anchor is day-specific
renderAll();
}
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', d.label + (d.isDeparture ? ' · dep' : ''))); // live label (dates)
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) {
const m = el('div', 'msg ' + role, html);
$('#chat-body').append(m);
m.scrollIntoView({ behavior: 'smooth', block: 'end' });
return m;
}
function typing() { return msg('ai', ''); }
function ai(html, delay = 700) {
const t = typing();
setTimeout(() => { t.remove(); msg('ai', html); scriptBusy = false; }, delay);
}
function chatSend() {
const v = $('#chat-input').value.trim();
if (!v) return;
$('#chat-input').value = '';
msg('user', v);
handleUser(v);
}
function handleUser(v) {
const s = v.toLowerCase();
if (disc) {
if (/reset/.test(s)) { disc.results = poolFor(disc.cat, disc.region); renderDiscover(); renderMap(); ai('Back to the full list.', 400); return; }
if (/done|exit|never ?mind|back to/.test(s)) { closeDiscover('Back to the plan.'); return; }
drawerFilter(v);
return;
}
if (/hotel|where.*sleep|stay/.test(s) && /find|alternative|option|another|compare|look/.test(s)) { openDiscover('hotel'); return; }
const slotWord = s.match(/(lunch|dinner|breakfast|snack)/);
const regWord = s.match(/\b(oltrarno|smn|duomo|city centre)\b/);
if (slotWord && regWord) { // “lunch near Oltrarno” → placeholder · “find a lunch in Oltrarno” → browse
if (/find|looking|search|options/.test(s)) openDiscover(slotWord[1], REGION_MAP[regWord[1]]);
else addRegionStop(slotWord[1], REGION_MAP[regWord[1]]);
return;
}
if (/find|looking|want|search|options|another|different|discover/.test(s) && slotWord) { openDiscover(slotWord[1]); return; }
if (/find|looking|want|search|options|another|different|discover/.test(s) && /activit|afternoon|things to do/.test(s)) { openDiscover('visit'); return; }
// Everything else goes to the model. There are NO keyword intercepts for
// questions or edits any more: “where should I eat lunch?”, “move X to after
// lunch”, “your marker is in the sea” all need the LLM — and the model has
// real tools for all of them (web/POI search, routing, plan fix-ops, and a
// map screenshot so it can see what the user sees).
// THREE states, not two. The old code refused as soon as the model wasn't
// 'loaded', so a freshly-restarted llama server (model registered but not
// loaded) could never be woken up: the request never reached the router,
// its models_autoload never fired, and the user just saw "offline".
if (llmOn) { askLLM(v); return; }
if (llmState === 'unloaded') {
// Router is up, model isn't loaded: load it, then handle the request.
ai('The planning model isn’t loaded yet — sending your request now, which loads it. The first turn after a server restart takes a minute or two.', 900);
ensureLLM().then(ok => {
if (ok) askLLM(v);
else ai('The planning model is offline right now, so I can’t take free-form requests. I can still open the browse drawer — try “find a lunch spot” or “show hotels” — and every card on the map has alternatives you can swap in directly.', 900);
});
return;
}
ai('The planning model is offline right now, so I can’t take free-form requests. I can still open the browse drawer — try “find a lunch spot” or “show hotels” — and every card on the map has alternatives you can swap in directly.', 900);
}
$('#chat-send').onclick = chatSend;
// LLM thinking toggle — near the send box. on = deliberate (better planning),
// off = quick answers. Persisted per browser, like the travel-mode preference.
function renderThinkToggle() {
const b = $('#think-toggle'); if (!b) return;
b.classList.toggle('on', thinkOn);
b.textContent = thinkOn ? '💭 on' : '💭 off';
b.title = thinkOn
? 'LLM thinking ON — the model reasons before answering (better planning, slower). Click for quick answers.'
: 'LLM thinking OFF — fast, direct answers (no hidden reasoning). Click to let it think.';
}
$('#think-toggle').onclick = () => { thinkOn = !thinkOn; try { localStorage.setItem('trips.think', thinkOn ? 'on' : 'off'); } catch {}; renderThinkToggle(); };
renderThinkToggle();
$('#chat-input').addEventListener('keydown', e => e.key === 'Enter' && chatSend());
// ---- flight/booking ingestion: drop a real ticket, the model re-anchors ----
function fileToB64(file) {
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(String(r.result).split(',')[1] || '');
r.onerror = () => rej(r.error);
r.readAsDataURL(file);
});
}
async function ingestDoc(file) {
if (!file) return;
if (scriptBusy) { msg('ai', 'One moment — I’m still working on the last request.'); return; }
const note = msg('user', '📎 ' + esc(file.name) + ' · ' + Math.max(1, Math.round(file.size / 1024)) + ' KB — reading…');
let b64;
try { b64 = await fileToB64(file); }
catch { note.remove(); return msg('ai', 'Couldn’t read that file.'); }
try {
// save with the trip on the server (original + extracted text); fall back
// to the stateless parser if the store endpoint is unavailable
const url = tripId ? '/trip-store/' + encodeURIComponent(tripId) + '/doc' : '/parse-doc';
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, mime: file.type || '', b64 }) });
const j = await r.json();
if (!j.ok) { note.remove(); return msg('ai', '⚠️ ' + esc(j.error || 'couldn’t read that file')); }
note.remove();
reconfigureFromDoc(j);
} catch { note.remove(); msg('ai', 'The document reader is unreachable right now.'); }
}
// attached documents stay in the model's context on EVERY turn, so follow-up
// questions ("what’s my confirmation code?", "when’s the flight out?") work
// without a re-upload
function docSection() {
// the full extracted text rides in the context every turn (it's already
// capped at 60k chars server-side — well within the 131k-token window)
const ds = Object.values(tripDocs);
if (!ds.length) return '';
return '\n\nAttached documents (the user’s own files, kept for reference on any question). These are ALL the documents on file for this trip — when one is missing a detail (e.g. an e-mail that only covers the outbound flight), look for it in the OTHERS before asking the user. For questions about their real bookings (confirmation codes, seats, prices, flight times), answer from these documents, not from the plan’s placeholder bookings:\n' +
ds.map(d => 'DOCUMENT “' + d.name + '”' + (d.truncated ? ' (extract truncated)' : '') + '\n' + (d.text || '')).join('\n\n');
}
function renderDocChips() {
const el = $('#doc-chips'); if (!el) return;
const names = Object.keys(tripDocs);
if (!names.length) { el.classList.add('hidden'); el.innerHTML = ''; return; }
el.classList.remove('hidden');
el.innerHTML = names.map(n =>
'📎 ' + esc(n) + ' ' + (Math.round((tripDocs[n].chars || 0) / 100) / 10) + ' KB').join(' ');
}
function reconfigureFromDoc(j) {
// keep the document on file: it persists with the trip and rides in the
// model's context on every turn
tripDocs[j.name] = { name: j.name, kind: j.kind, chars: j.chars, text: j.text, truncated: !!j.truncated, at: Date.now(), docId: j.docId || null, originalSize: j.originalSize || 0 };
renderDocChips();
msg('user', '📎 ' + esc(j.name) + ' ' + (j.kind || 'doc') + ' · ' + (j.chars || 0) + ' chars — attached' + (j.truncated ? ' (truncated)' : '') + '');
if (!llmOn) return msg('ai', 'I read the document (' + (j.chars || 0) + ' chars) and kept it attached, but the model is offline so I can’t act on it yet. I’ll do it as soon as it’s back.');
const prompt =
'I’ve attached a document for this trip (extracted text below). It stays on file for later questions, so no need to re-list its contents in your reply.' + (j.truncated ? ' NOTE: the extraction was truncated at the end — if a needed detail is missing, say so and ask me to paste it.' : '') + '\n\n' +
'DOCUMENT “' + (j.name || 'document') + '”\n' +
'\n' + (j.text || '') + '\n\n\n' +
'If this is a flight booking (or contains actual flight times), reconfigure the trip around it:\n' +
'1. Read the ARRIVAL (date, local time, airport, city) and the DEPARTURE (date, local time, airport, city). With connections, the arrival is where the trip starts (first city) and the departure where it ends (last city). If this document only shows one leg, search the OTHER attached documents in your context for the missing leg before asking the user.\n' +
'2. Call apply_flight_anchors exactly once with those two anchors — apply it now, do not ask first (the edit is undo-able), even if the dates differ from the current plan.\n' +
'3. Confirm what changed — the new first and last day with their start/end times, any stops that had to go, and the date shift — in a couple of sentences.\n' +
'If it is NOT a flight booking, do not change the plan — just confirm it’s attached and summarize what it contains in 1–2 sentences.';
askLLM(prompt);
}
// drag & drop a file anywhere on the chat panel
(function () {
const chat = $('#chat'); if (!chat) return;
const hasFiles = e => e.dataTransfer && Array.prototype.slice.call(e.dataTransfer.types || []).indexOf('Files') >= 0;
chat.addEventListener('dragover', e => { if (hasFiles(e)) { e.preventDefault(); chat.classList.add('dropping'); } });
chat.addEventListener('dragenter', e => { if (hasFiles(e)) { e.preventDefault(); chat.classList.add('dropping'); } });
chat.addEventListener('dragleave', e => { if (!chat.contains(e.relatedTarget)) chat.classList.remove('dropping'); });
chat.addEventListener('drop', e => { e.preventDefault(); chat.classList.remove('dropping'); const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) ingestDoc(f); });
})();
// 📎 attach button (same path as drag & drop)
const docFile = $('#doc-file');
const docAttach = $('#doc-attach');
if (docAttach && docFile) {
docAttach.onclick = () => docFile.click();
docFile.addEventListener('change', e => { const f = e.target.files && e.target.files[0]; if (f) ingestDoc(f); e.target.value = ''; });
}
// ---------------- landing → parse → plan ----------------
// 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 = '';
sampleRows().forEach(([k, v, note, c]) => {
const tr = document.createElement('tr');
tr.innerHTML = `| ${k} | ${v}${c === 'hi' ? 'confirmed' : 'verify'} ${note} | `;
t.append(tr);
});
$('#parse-modal').classList.remove('hidden');
}
$('#parse-cancel').onclick = () => $('#parse-modal').classList.add('hidden');
$('#parse-confirm').onclick = startApp;
// ?autostart=plan|map|chat — skips the landing screen (dev/testing)
const auto = new URLSearchParams(location.search).get('autostart');
if (auto) setTimeout(() => { startApp(); if (auto !== 'plan' && matchMedia('(max-width: 860px)').matches) document.querySelector(`#mobiletabs button[data-t=${auto}]`)?.click(); }, 120);
// ---------------- multi-trip: registry, per-trip persistence, switching --------
// All trips live in window.TRIPS. Each trip's working state (days/stays +
// version history) is persisted per trip. The SINGLE SOURCE OF TRUTH is the
// server copy (~/trips/store/mock//plan.json, via /trip-store//plan)
// — the same home as docs and chat. localStorage is now only a local cache
// (offline + instant paint); a first visit with an old localStorage-only plan
// is migrated up to the server on load.
let tripId = window.MOCK_CITY;
const tripStore = {
key: id => 'tripstate.v1.' + id,
save(id, data) { try { localStorage.setItem(this.key(id), JSON.stringify(data)); } catch {} },
load(id) { try { const s = localStorage.getItem(this.key(id)); return s ? JSON.parse(s) : null; } catch { return null; } },
};
let persistT = 0;
function planPayload() {
const hist = history.slice(0, hIdx + 1).slice(-25); // drop the redo tail, cap at 25
return { days, stays, version, hIdx: hist.length - 1, history: hist, bookings: M.trip.bookings, title: M.trip.title, savedAt: Date.now() };
}
function persistTrip(force = false) {
clearTimeout(persistT);
// force: skip the debounce — the re-anchoring of the whole trip around
// booked flights is the one edit we must never lose
const push = () => {
const p = planPayload();
tripStore.save(tripId, p); // local cache (offline fallback)
fetch('/trip-store/' + encodeURIComponent(tripId) + '/plan', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(p),
}).then(r => r.json()).then(j => { if (!j.ok) console.warn('[plan] server save failed:', j.error); }).catch(() => {});
};
if (force) push(); else persistT = setTimeout(push, 250);
}
function loadServerPlan(id) {
// {state:'saved',plan} | {state:'none'} (server says no plan yet) | {state:'error'}
// — 'error' (server unreachable) is deliberately distinct, so a transient
// failure never makes the app believe a saved trip is brand new.
return fetch('/trip-store/' + encodeURIComponent(id) + '/plan')
.then(r => r.json())
.then(j => (j && j.ok && j.plan) ? { state: 'saved', plan: j.plan } : { state: 'none' })
.catch(() => ({ state: 'error' }));
}
function renderTripMenu() {
$('#crumb').innerHTML = `🧳 ${M.trip.title} ▾`;
const menu = $('#trip-menu');
menu.innerHTML = '';
menu.append(el('div', 'ti-head', 'Trips — each keeps its own plan'));
Object.values(window.TRIPS).forEach(t => {
const saved = tripStore.load(t.id);
const b = el('button', 'trip-item' + (t.id === tripId ? ' cur' : ''));
b.innerHTML = `${t.trip.title}${t.id === tripId ? 'current trip' : 'open · v' + (saved?.version || 1)}`;
b.onclick = () => { menu.classList.add('hidden'); switchTrip(t.id); };
menu.append(b);
});
const more = el('button', 'trip-item');
more.innerHTML = `+ New trip…start from a booking on the landing screen`;
more.onclick = () => location.href = location.pathname;
menu.append(more);
const ex = el('button', 'trip-item');
ex.innerHTML = `⬇ Export plan (JSON)download the server-side plan file`;
ex.onclick = () => {
menu.classList.add('hidden');
fetch('/trip-store/' + encodeURIComponent(tripId) + '/plan').then(r => r.json()).then(j => {
const p = (j.ok && j.plan) ? j.plan : planPayload(); // fall back to live state
const blob = new Blob([JSON.stringify(p, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = tripId + '-plan-v' + p.version + '.json';
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}).catch(() => ai('Couldn’t fetch the plan file from the server.', 400));
};
menu.append(ex);
}
function enterTrip(id) {
tripId = id;
tripActive = true; // on a trip now → the app may proactively keep the model loaded/warm
M = window.TRIPS[id];
days = {}; M.days.forEach(d => days[d.id] = deep(d));
curDay = 'D1'; day = days[curDay];
// travel mode (on foot vs taxi) is an app-level preference, remembered
try { const tm = localStorage.getItem('trips.travemode'); if (tm === 'car' || tm === 'foot') travelMode = tm; } catch {}
stays = deep(M.trip.stays);
staySeq = stays.reduce((m, o) => Math.max(m, +String(o.id).replace(/\D/g, '') || 0), 1);
// resume this trip's saved state (edits + version history), if any.
// Server copy is authoritative; the localStorage cache is the fallback and
// is migrated up on first contact, so an old local-only plan is not lost.
const local = tripStore.load(id);
loadServerPlan(id).then(res => {
let saved = res.state === 'saved' ? res.plan : null;
if (!saved && local) { // no server copy (or it's unreachable) → local cache
saved = local;
if (res.state === 'none') persistTrip(true); // one-time migration up to the server
}
if (saved) applySavedPlan(saved);
else { commit('initial plan'); script1(); } // genuinely new trip
});
tripDocs = {}; // rehydrated from the server store below
// reset per-trip UI state
chatLog = []; compareItems = [];
loadTripStore(id); // docs (original + parsed) + conversation, per trip
closeDiscover(); closeL3();
scope = 'day';
document.querySelectorAll('.sct').forEach(b => b.classList.toggle('on', b.dataset.sc === 'day'));
$('#daystrip').classList.remove('hidden');
$('#rail-foot').classList.remove('hidden');
offlineState = 'idle';
const ob = $('#offline-badge'); if (ob) { ob.classList.remove('ok'); ob.textContent = badgeText(); }
$('#chat-body').innerHTML = '';
scriptBusy = false;
legTokens = {}; // drop in-flight route enrichment from the previous trip
map.setView(M.trip.places[0].center, 14);
renderTripMenu();
buildDayTabs();
renderHotelMks(); renderModeToggle();
renderDocChips();
// this trip may live on a different OSRM extract — re-check before routing
Promise.all([checkRouter(), checkLLM(), checkWeb(), checkSpatial()]).then(() => {
const b = $('#offline-badge');
if (b && offlineState !== 'ready') b.textContent = badgeText();
if (routerOn) enrichLegs(day);
if (webOn && $('#sugg-block')) renderSugg(); // web came up late → enrich the suggestion rows now
// Get the model ready for the first turn: already loaded → warm it;
// unloaded (fresh restart / router eviction) → LOAD it in the background
// so the user's first message isn't a cold start.
if (llmOn) warmLLM();
else if (llmState === 'unloaded' && !scriptBusy)
ensureLLM().then(ok => { if (ok) warmLLM(); }); // load it, then warm the KV prefix
});
if (!day.legs.length) rebuildLegs(day); else enrichLegs(day);
renderAll();
renderVersionPill();
// NOTE: the saved-state branch (welcome-back vs script1 onboarding) runs in
// loadServerPlan()'s .then → applySavedPlan(), because the plan now lives on
// the server. Until it resolves (~50 ms locally) the UI shows the base plan.
}
function applySavedPlan(saved) {
days = deep(saved.days); stays = deep(saved.stays); version = saved.version; history = saved.history; hIdx = saved.hIdx;
// days is a NEW object tree — re-point the current-day reference, which enterTrip()
// already set to the (now stale) base-plan day; without this the rail would render
// the base plan instead of the saved one until the user clicked a day tab
curDay = days[curDay] ? curDay : 'D1';
day = days[curDay];
if (saved.bookings) M.trip.bookings = deep(saved.bookings);
if (saved.title != null) M.trip.title = saved.title;
// self-heal plans saved by the old re-anchoring, which re-dated the days
// but not the stays (hotel nights then no longer "covered" any day);
// idempotent — a consistent plan is a no-op
redateStaysAndPlaces();
healTravelAnchors(); // rebuild the arrival/departure airport cards + transfers
// re-sync coordinates against the corrected source dataset — a plan saved
// before the old-city/airport coordinate fix would otherwise still hold the
// misplaced (in-the-bay) points. idempotent: an in-sync plan is a no-op
const coordFixed = healCoordinates();
const transferFixed = healTransfers(); // re-sync transfer endpoints, drop implausible stored routes
if (coordFixed) Object.values(days).forEach(d => rebuildLegs(d, false)); // re-estimate legs from the new points
persistTrip(coordFixed || transferFixed);
buildDayTabs(); renderHotelMks(); renderModeToggle(); renderDocChips(); renderTripMenu();
renderAll(); renderVersionPill();
if (transferFixed && day) enrichLegs(day); // re-route the repaired transfers (and the day's legs) in the background
ai(`Welcome back to ${M.trip.title} — you’re at v${version} (“${history[hIdx].label}”). Your earlier edits are kept; switch trips any time from the menu, top-left.`, 600);
}
function switchTrip(id) {
if (id === tripId) return;
persistTrip();
$('#trip-menu').classList.add('hidden');
enterTrip(id);
setTimeout(() => map.invalidateSize(), 80);
}
$('#crumb').onclick = e => { e.stopPropagation(); $('#trip-menu').classList.toggle('hidden'); };
document.addEventListener('click', e => { if (!e.target.closest('#trip-picker')) $('#trip-menu').classList.add('hidden'); });
function startApp() {
$('#parse-modal').classList.add('hidden');
$('#landing').classList.add('hidden');
$('#topbar').classList.remove('hidden');
$('#app').classList.remove('hidden');
enterTrip(tripId);
}
// ---------------- detail sheet ----------------
// the travel-mode toggle: on foot vs taxi between stops (the hotel itself now
// lives in the day timeline as the "tonight" card, not in this block)
// the toggle UI lives on the route card (openL3); this just refreshes its
// state whenever the mode changes or the rail re-renders
function renderModeToggle() {
renderL3Mode(l3 ? l3.leg : null);
}
function setTravelMode(m) {
if (m === travelMode) return;
travelMode = m;
try { localStorage.setItem('trips.travemode', m); } catch {}
// global mode: force every on-ground leg (and the airport drop-off) to the new mode,
// then rebuild — which now preserves each leg's mode, so the override sticks. Enrichment
// is not queued per day (one fetch per day would cancel the token out); only the day you're
// looking at is re-routed in the background.
M.days.forEach(md => {
const d = days[md.id]; if (!d) return;
(d.legs || []).forEach(g => { if (g && (g.mode === 'foot' || g.mode === 'car' || g.vague)) g.mode = m; });
if (d.transferIn && d.transferIn.at) { d.transferIn.mode = m; d.transferIn._routedMode = null; }
if (d.retLeg) { d.retLeg.mode = m; d.retLeg._routedMode = null; } // the return ride follows the global choice (rebuildLegs below carries the mode)
rebuildLegs(d, false);
});
renderAll();
if (day) enrichLegs(day);
renderModeToggle();
if (l3) (l3.kind === 'arrival' ? openArrivalEditor() : openL3(l3.idx)); // rebind the open route card to the re-estimated leg
}
function openDetail(s, actions) {
const d = s.detail; if (!d) return;
const oldM = document.getElementById('detail-modal'); if (oldM) oldM.remove();
const modal = el('div', 'modal'); modal.id = 'detail-modal';
const card = el('div', 'modal-card wide');
const img = el('div', 'd-img');
img.style.background = s.img[2];
if (s.img[3]) { const im = document.createElement('img'); im.src = s.img[3]; im.alt = s.img[1]; img.append(im); }
else img.append(el('div', 'd-emoji', s.img[0]));
img.append(el('div', 'd-attr', s.img[1] + ' · Wikimedia Commons'));
const body = el('div', 'd-body');
const t = el('div', 'd-title');
const a = el('a', null, s.name); a.href = s.url; a.target = 'blank'; a.rel = 'noopener';
t.append(a, el('span', 'd-kind', s.kind));
body.append(t);
const facts = el('div', 'd-facts');
facts.append(
el('span', 'tc ' + CONF_CLS[s.durConf], 'planned ' + fmt(s.dur)),
el('span', 'tc t-search', 'typically ' + fmt(s.suggested.value)),
s.price ? el('span', 'pchip', '~' + s.price.amount + s.price.cur + (s.mealKind ? ' / person' : '')) : null
);
body.append(facts);
body.append(el('div', 'd-summary', d.summary));
const links = el('div', 'd-links');
d.links.forEach(l => {
const b = el('a', 'd-link', l.label); b.href = l.href; b.target = 'blank'; b.rel = 'noopener';
links.append(b);
});
body.append(links);
if (actions) {
const foot = el('div', 'd-actions');
const a2 = el('button', 'btn primary small', 'Add to day'); a2.onclick = actions.add;
const m2 = el('button', 'btn ghost small', 'Idea'); m2.onclick = actions.maybe;
const p2 = el('button', 'btn ghost small', 'Not for me'); p2.onclick = actions.pass;
foot.append(a2, m2, p2);
actions.register([a2, m2, p2]);
body.append(foot);
}
const close = el('button', 'd-close', '✕');
close.onclick = () => modal.remove();
card.append(img, body, close);
modal.append(card);
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
document.body.append(modal);
}
// ---------------- adding stops (planned / maybe / alt) ----------------
function addStopToDay(id, requested) {
const src = allStops(id);
const copy = deep(src);
const hasPrimary = day.stops.some(x => x.slot === copy.slot && x.state === 'planned');
let state = requested || (hasPrimary ? 'alt' : 'planned');
if (state === 'planned') {
const cur = day.stops.find(x => x.slot === copy.slot && x.state === 'planned');
if (cur) cur.state = 'alt';
}
copy.state = state;
day.stops.push(copy);
rebuildLegs();
commit(`${copy.name} → ${stateLabel(state)}`);
renderAll([id]);
return state;
}
function script1() {
setTimeout(() => {
scriptBusy = true;
{ const b0 = dayBase(); ai(`Anchored your stay — ${b0.name}, ${stayRange(b0)}; bookings are pinned as hard anchors. The drawer on the map (lunch / afternoon / hotel) is open whenever you want to browse — what kind of trip are you after?`, 900); }
setTimeout(() => {
const m = msg('ai', 'Pick a vibe — or tell me in your own words:');
const chips = el('div', 'chips');
M.vibes.forEach(v => {
const c = el('button', 'chip', v.label);
c.onclick = () => { if (scriptBusy) return; [...chips.children].forEach(x => x.classList.remove('sel')); c.classList.add('sel'); script2(v.label); };
chips.append(c);
});
m.append(chips);
}, 1800);
}, 300);
}
function script2(vibe) {
scriptBusy = true;
ai(`Noted — ${vibe || M.vibes[0].label}. A few ideas for your first day:`, 900);
setTimeout(() => {
const m = msg('ai', '');
const cards = el('div', 'cards');
m.append(cards);
let sequenced = false;
M.suggestions.forEach((sg, i) => {
const s = M.days.flatMap(d => d.stops).find(x => x.id === sg.stop);
const card = el('div', 'scard');
const imgBox = el('div', 'scard-img');
imgBox.style.background = s.img[2];
const skel = el('div', 'skel');
const ready = () => { if (skel.isConnected) skel.remove(); imgBox.classList.add('done'); };
imgBox.append(skel, el('div', 'attr', 'Wikimedia Commons'));
if (s.img[3]) {
const im = document.createElement('img');
im.src = s.img[3]; im.alt = s.img[1];
im.onload = ready; im.onerror = ready;
imgBox.append(im);
} else {
imgBox.append(el('div', 'ph', s.img[0]));
setTimeout(ready, 1400 + i * 500);
}
const body = el('div', 'scard-body');
const title = el('div', 'scard-name');
const tl = el('a', null, s.name); tl.href = s.url; tl.target = '_blank'; tl.rel = 'noopener';
title.append(tl);
body.append(title);
body.append(el('div', 'scard-pitch', sg.pitch));
const foot = el('div', 'scard-foot');
const add = el('button', 'btn primary small', 'Add to day');
const maybeB = el('button', 'btn ghost small', 'Idea');
const pass = el('button', 'btn ghost small', 'Not for me');
const pc = el('span', 'pchip sp', s.price ? `~${s.price.amount}${s.price.cur}` : '');
const st = { done: false };
const sheetBtns = [];
const finish = labels => [add, maybeB, pass, ...sheetBtns].forEach((b, n) => { b.disabled = true; if (labels[n]) b.textContent = labels[n]; });
const doAdd = () => { if (st.done) return; st.done = true;
card.classList.add('added');
finish(['Added ✓', '', '', 'Added ✓', '', '']);
const state = addStopToDay(s.id, null);
if (state === 'alt') ai(`${s.name} is in as the backup for your ${s.slot} — one slot, one plan. Swap them any time.`, 500);
else ai(`Added ${s.name} — I’ll sequence the day once the set is done.`, 500);
if (!sequenced) { sequenced = true; setTimeout(script3, 1500); }
};
const doMaybe = () => { if (st.done) return; st.done = true;
card.classList.add('maybe-card');
finish(['', 'Holding', '', '', 'Holding', '']);
addStopToDay(s.id, 'maybe');
ai(`Holding ${s.name} as an idea — dashed on the day, not counted against the time budget.`, 500);
if (!sequenced) { sequenced = true; setTimeout(script3, 1500); }
};
const doPass = () => { if (st.done) return; st.done = true;
card.classList.add('added', 'dropped');
finish(['', '', 'Ruled out', '', '', 'Ruled out']);
cards.appendChild(card);
const dm = document.getElementById('detail-modal'); if (dm) dm.remove();
excluded.add(s.name.split('—').pop().trim()); // feeds future slot searches
ai(`Ruled out ${s.name} — noted as a preference.`, 500);
};
add.onclick = doAdd; maybeB.onclick = doMaybe; pass.onclick = doPass;
card.addEventListener('click', e => { if (!e.target.closest('button, a')) { flyTo(s); openDetail(s, { add: doAdd, maybe: doMaybe, pass: doPass, register: b => sheetBtns.push(...b) }); } });
card.addEventListener('mouseenter', () => tempShow(s));
card.addEventListener('mouseleave', tempClear);
foot.append(add, maybeB, pass, pc);
body.append(foot);
const en = el('div', 'scard-enrich', sg.enrich);
body.append(en);
card.append(imgBox, body);
cards.append(card);
setTimeout(() => en.classList.add('show'), 2600 + i * 500);
});
m.scrollIntoView({ behavior: 'smooth' });
}, 1900);
}
function script3() {
setTimeout(() => {
rebuildLegs(day);
renderAll(day.stops.map(s => s.id));
const st = dayStats(day);
ai(`Day sequenced — ${(st.dur / 60).toFixed(1)} h of ${day.wakingHours} h waking, ending ~${fmt(st.end)}. Anything that looks tight shows up as a nudge in the rail.`, 1100);
}, 1400);
}
// ---------------- landing wiring ----------------
const dz = $('#dropzone');
['dragenter', 'dragover'].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add('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-sample').onclick = showParse;
$('#dest-go').onclick = () => { showParse(); };
$('#dest').addEventListener('keydown', e => e.key === 'Enter' && $('#dest-go').click());
// ?city= preselects the demo city on the landing screen; the demo chips switch dataset
(function () {
const c = new URLSearchParams(location.search).get('city');
if (c && M.trip.places[0]) $('#dest').value = M.trip.places[0].name;
document.querySelectorAll('#land-demo .chip').forEach(b => b.onclick = () => {
location.href = location.pathname + '?city=' + b.dataset.city;
});
})();
// drop a candidate onto the plan panel itself → hold it as an idea
(function () {
const body = $('#rail-body'); if (!body) return;
body.addEventListener('dragover', e => { if (dragCand) e.preventDefault(); });
body.addEventListener('drop', e => {
const data = e.dataTransfer.getData('text/plain');
if (!data.startsWith('cand:')) return;
e.preventDefault();
const found = findCand(data.slice(5));
if (!found) return;
const { c, slot } = found; dragCand = null;
day.stops.push(makeStop(c, slot, 'maybe'));
rebuildLegs(); commit(`idea: ${c.name}`); renderAll([c.id]);
if (disc) closeDiscover();
ai(`Dropped ${c.name} into ${dayLabel()} as an idea — promote it whenever you like.`, 500);
});
})();
initMap();
// router status + leg enrichment happen in enterTrip() (per-trip extract)